@donkeylabs/server 0.4.8 → 0.5.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/docs/external-jobs.md +131 -11
- package/docs/workflows.md +150 -56
- 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 +2 -1
- package/src/client/base.ts +6 -4
- package/src/core/external-job-socket.ts +142 -21
- package/src/core/index.ts +5 -0
- package/src/core/job-adapter-sqlite.ts +287 -0
- package/src/core/jobs.ts +36 -3
- package/src/core/workflows.ts +202 -49
- package/src/core.ts +73 -4
- package/src/index.ts +12 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Donkeylabs External Job Python Wrapper
|
|
3
|
+
|
|
4
|
+
This module provides a simple interface for Python scripts to communicate
|
|
5
|
+
with the Donkeylabs job system via Unix sockets or TCP.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from donkeylabs_job import DonkeylabsJob, run_job
|
|
9
|
+
|
|
10
|
+
def my_job(job: DonkeylabsJob):
|
|
11
|
+
job.progress(0, "Starting...")
|
|
12
|
+
# Do work...
|
|
13
|
+
job.progress(50, "Halfway done")
|
|
14
|
+
# More work...
|
|
15
|
+
return {"result": "success"}
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
run_job(my_job)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Callable, Dict, Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DonkeylabsJob:
|
|
31
|
+
"""Interface for communicating with the Donkeylabs job system."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
job_id: str,
|
|
36
|
+
name: str,
|
|
37
|
+
data: Any,
|
|
38
|
+
socket_path: str,
|
|
39
|
+
heartbeat_interval: float = 5.0,
|
|
40
|
+
reconnect_interval: float = 2.0,
|
|
41
|
+
max_reconnect_attempts: int = 30,
|
|
42
|
+
):
|
|
43
|
+
self.job_id = job_id
|
|
44
|
+
self.name = name
|
|
45
|
+
self.data = data
|
|
46
|
+
self._socket_path = socket_path
|
|
47
|
+
self._heartbeat_interval = heartbeat_interval
|
|
48
|
+
self._reconnect_interval = reconnect_interval
|
|
49
|
+
self._max_reconnect_attempts = max_reconnect_attempts
|
|
50
|
+
self._socket: Optional[socket.socket] = None
|
|
51
|
+
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
52
|
+
self._reconnect_thread: Optional[threading.Thread] = None
|
|
53
|
+
self._running = False
|
|
54
|
+
self._connected = False
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._reconnect_lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def connect(self) -> None:
|
|
59
|
+
"""Connect to the job server socket."""
|
|
60
|
+
self._do_connect()
|
|
61
|
+
self._running = True
|
|
62
|
+
self._connected = True
|
|
63
|
+
self._start_heartbeat()
|
|
64
|
+
self._send_started()
|
|
65
|
+
|
|
66
|
+
def _do_connect(self) -> None:
|
|
67
|
+
"""Internal connection logic."""
|
|
68
|
+
if self._socket_path.startswith("tcp://"):
|
|
69
|
+
# TCP connection (Windows fallback)
|
|
70
|
+
addr = self._socket_path[6:] # Remove "tcp://"
|
|
71
|
+
host, port = addr.rsplit(":", 1)
|
|
72
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
73
|
+
self._socket.connect((host, int(port)))
|
|
74
|
+
else:
|
|
75
|
+
# Unix socket
|
|
76
|
+
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
77
|
+
self._socket.connect(self._socket_path)
|
|
78
|
+
|
|
79
|
+
def _try_reconnect(self) -> bool:
|
|
80
|
+
"""Attempt to reconnect to the server (for server restart resilience)."""
|
|
81
|
+
with self._reconnect_lock:
|
|
82
|
+
if self._connected:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
print(f"[DonkeylabsJob] Attempting to reconnect...", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
for attempt in range(self._max_reconnect_attempts):
|
|
88
|
+
try:
|
|
89
|
+
# Close old socket
|
|
90
|
+
if self._socket:
|
|
91
|
+
try:
|
|
92
|
+
self._socket.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# Try to reconnect
|
|
97
|
+
self._do_connect()
|
|
98
|
+
self._connected = True
|
|
99
|
+
print(f"[DonkeylabsJob] Reconnected after {attempt + 1} attempts", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
# Send started message to let server know we're back
|
|
102
|
+
self._send_started()
|
|
103
|
+
return True
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"[DonkeylabsJob] Reconnect attempt {attempt + 1}/{self._max_reconnect_attempts} failed: {e}", file=sys.stderr)
|
|
106
|
+
time.sleep(self._reconnect_interval)
|
|
107
|
+
|
|
108
|
+
print(f"[DonkeylabsJob] Failed to reconnect after {self._max_reconnect_attempts} attempts", file=sys.stderr)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def disconnect(self) -> None:
|
|
112
|
+
"""Disconnect from the job server."""
|
|
113
|
+
self._running = False
|
|
114
|
+
if self._heartbeat_thread:
|
|
115
|
+
self._heartbeat_thread.join(timeout=2.0)
|
|
116
|
+
if self._socket:
|
|
117
|
+
try:
|
|
118
|
+
self._socket.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def _send_message(self, message: Dict[str, Any]) -> bool:
|
|
123
|
+
"""Send a JSON message to the server. Returns True if sent successfully."""
|
|
124
|
+
if not self._socket:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
message["jobId"] = self.job_id
|
|
128
|
+
message["timestamp"] = int(time.time() * 1000)
|
|
129
|
+
|
|
130
|
+
with self._lock:
|
|
131
|
+
try:
|
|
132
|
+
data = json.dumps(message) + "\n"
|
|
133
|
+
self._socket.sendall(data.encode("utf-8"))
|
|
134
|
+
return True
|
|
135
|
+
except (BrokenPipeError, ConnectionResetError, OSError) as e:
|
|
136
|
+
print(f"[DonkeylabsJob] Connection lost: {e}", file=sys.stderr)
|
|
137
|
+
self._connected = False
|
|
138
|
+
|
|
139
|
+
# Try to reconnect in background (don't block the caller)
|
|
140
|
+
if self._running and not self._reconnect_thread:
|
|
141
|
+
self._reconnect_thread = threading.Thread(
|
|
142
|
+
target=self._reconnect_loop,
|
|
143
|
+
daemon=True
|
|
144
|
+
)
|
|
145
|
+
self._reconnect_thread.start()
|
|
146
|
+
return False
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print(f"[DonkeylabsJob] Failed to send message: {e}", file=sys.stderr)
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _reconnect_loop(self) -> None:
|
|
152
|
+
"""Background thread that attempts to reconnect."""
|
|
153
|
+
if self._try_reconnect():
|
|
154
|
+
print(f"[DonkeylabsJob] Reconnection successful, resuming operation", file=sys.stderr)
|
|
155
|
+
else:
|
|
156
|
+
print(f"[DonkeylabsJob] Reconnection failed, job may be lost", file=sys.stderr)
|
|
157
|
+
self._reconnect_thread = None
|
|
158
|
+
|
|
159
|
+
def _send_started(self) -> None:
|
|
160
|
+
"""Send a started message to the server."""
|
|
161
|
+
self._send_message({"type": "started"})
|
|
162
|
+
|
|
163
|
+
def _start_heartbeat(self) -> None:
|
|
164
|
+
"""Start the background heartbeat thread."""
|
|
165
|
+
|
|
166
|
+
def heartbeat_loop():
|
|
167
|
+
while self._running:
|
|
168
|
+
self._send_message({"type": "heartbeat"})
|
|
169
|
+
time.sleep(self._heartbeat_interval)
|
|
170
|
+
|
|
171
|
+
self._heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True)
|
|
172
|
+
self._heartbeat_thread.start()
|
|
173
|
+
|
|
174
|
+
def progress(
|
|
175
|
+
self,
|
|
176
|
+
percent: float,
|
|
177
|
+
message: Optional[str] = None,
|
|
178
|
+
**data: Any,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Report progress to the job server.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
percent: Progress percentage (0-100)
|
|
185
|
+
message: Optional status message
|
|
186
|
+
**data: Additional data to include
|
|
187
|
+
"""
|
|
188
|
+
msg: Dict[str, Any] = {
|
|
189
|
+
"type": "progress",
|
|
190
|
+
"percent": percent,
|
|
191
|
+
}
|
|
192
|
+
if message:
|
|
193
|
+
msg["message"] = message
|
|
194
|
+
if data:
|
|
195
|
+
msg["data"] = data
|
|
196
|
+
|
|
197
|
+
self._send_message(msg)
|
|
198
|
+
|
|
199
|
+
def log(
|
|
200
|
+
self,
|
|
201
|
+
level: str,
|
|
202
|
+
message: str,
|
|
203
|
+
**data: Any,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Send a log message to the job server.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
level: Log level (debug, info, warn, error)
|
|
210
|
+
message: Log message
|
|
211
|
+
**data: Additional data to include
|
|
212
|
+
"""
|
|
213
|
+
msg: Dict[str, Any] = {
|
|
214
|
+
"type": "log",
|
|
215
|
+
"level": level,
|
|
216
|
+
"message": message,
|
|
217
|
+
}
|
|
218
|
+
if data:
|
|
219
|
+
msg["data"] = data
|
|
220
|
+
|
|
221
|
+
self._send_message(msg)
|
|
222
|
+
|
|
223
|
+
def debug(self, message: str, **data: Any) -> None:
|
|
224
|
+
"""Send a debug log message."""
|
|
225
|
+
self.log("debug", message, **data)
|
|
226
|
+
|
|
227
|
+
def info(self, message: str, **data: Any) -> None:
|
|
228
|
+
"""Send an info log message."""
|
|
229
|
+
self.log("info", message, **data)
|
|
230
|
+
|
|
231
|
+
def warn(self, message: str, **data: Any) -> None:
|
|
232
|
+
"""Send a warning log message."""
|
|
233
|
+
self.log("warn", message, **data)
|
|
234
|
+
|
|
235
|
+
def error(self, message: str, **data: Any) -> None:
|
|
236
|
+
"""Send an error log message."""
|
|
237
|
+
self.log("error", message, **data)
|
|
238
|
+
|
|
239
|
+
def complete(self, result: Any = None) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Mark the job as completed.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
result: Optional result data to return
|
|
245
|
+
"""
|
|
246
|
+
msg: Dict[str, Any] = {"type": "completed"}
|
|
247
|
+
if result is not None:
|
|
248
|
+
msg["result"] = result
|
|
249
|
+
|
|
250
|
+
self._send_message(msg)
|
|
251
|
+
|
|
252
|
+
def fail(self, error: str, stack: Optional[str] = None) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Mark the job as failed.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
error: Error message
|
|
258
|
+
stack: Optional stack trace
|
|
259
|
+
"""
|
|
260
|
+
msg: Dict[str, Any] = {
|
|
261
|
+
"type": "failed",
|
|
262
|
+
"error": error,
|
|
263
|
+
}
|
|
264
|
+
if stack:
|
|
265
|
+
msg["stack"] = stack
|
|
266
|
+
|
|
267
|
+
self._send_message(msg)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_job(
|
|
271
|
+
handler: Callable[[DonkeylabsJob], Any],
|
|
272
|
+
heartbeat_interval: float = 5.0,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""
|
|
275
|
+
Run a job handler function.
|
|
276
|
+
|
|
277
|
+
This function reads the job payload from stdin, connects to the job server,
|
|
278
|
+
runs the handler, and reports the result.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
handler: A function that takes a DonkeylabsJob and returns the result
|
|
282
|
+
heartbeat_interval: How often to send heartbeats (seconds)
|
|
283
|
+
|
|
284
|
+
Example:
|
|
285
|
+
def my_job(job: DonkeylabsJob):
|
|
286
|
+
job.progress(0, "Starting...")
|
|
287
|
+
result = do_work(job.data)
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
run_job(my_job)
|
|
292
|
+
"""
|
|
293
|
+
# Read payload from stdin
|
|
294
|
+
payload_line = sys.stdin.readline()
|
|
295
|
+
if not payload_line:
|
|
296
|
+
print("No payload received on stdin", file=sys.stderr)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
payload = json.loads(payload_line)
|
|
301
|
+
except json.JSONDecodeError as e:
|
|
302
|
+
print(f"Failed to parse payload: {e}", file=sys.stderr)
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
|
|
305
|
+
job_id = payload.get("jobId")
|
|
306
|
+
name = payload.get("name")
|
|
307
|
+
data = payload.get("data")
|
|
308
|
+
socket_path = payload.get("socketPath")
|
|
309
|
+
|
|
310
|
+
# Fall back to environment variables if not in payload
|
|
311
|
+
if not job_id:
|
|
312
|
+
job_id = os.environ.get("DONKEYLABS_JOB_ID")
|
|
313
|
+
if not socket_path:
|
|
314
|
+
socket_path = os.environ.get("DONKEYLABS_SOCKET_PATH")
|
|
315
|
+
tcp_port = os.environ.get("DONKEYLABS_TCP_PORT")
|
|
316
|
+
if tcp_port and not socket_path:
|
|
317
|
+
socket_path = f"tcp://127.0.0.1:{tcp_port}"
|
|
318
|
+
|
|
319
|
+
if not job_id or not socket_path:
|
|
320
|
+
print("Missing jobId or socketPath", file=sys.stderr)
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
|
|
323
|
+
job = DonkeylabsJob(
|
|
324
|
+
job_id=job_id,
|
|
325
|
+
name=name or "unknown",
|
|
326
|
+
data=data,
|
|
327
|
+
socket_path=socket_path,
|
|
328
|
+
heartbeat_interval=heartbeat_interval,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
job.connect()
|
|
333
|
+
|
|
334
|
+
# Run the handler
|
|
335
|
+
result = handler(job)
|
|
336
|
+
|
|
337
|
+
# Send completion
|
|
338
|
+
job.complete(result)
|
|
339
|
+
except Exception as e:
|
|
340
|
+
import traceback
|
|
341
|
+
|
|
342
|
+
job.fail(str(e), traceback.format_exc())
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
finally:
|
|
345
|
+
job.disconnect()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# Example job handler
|
|
349
|
+
def example_handler(job: DonkeylabsJob) -> Dict[str, Any]:
|
|
350
|
+
"""Example job handler that processes data in steps."""
|
|
351
|
+
job.info(f"Starting job with data: {job.data}")
|
|
352
|
+
|
|
353
|
+
total_steps = job.data.get("steps", 5)
|
|
354
|
+
|
|
355
|
+
for i in range(total_steps):
|
|
356
|
+
progress = (i / total_steps) * 100
|
|
357
|
+
job.progress(progress, f"Processing step {i + 1} of {total_steps}")
|
|
358
|
+
time.sleep(0.5) # Simulate work
|
|
359
|
+
|
|
360
|
+
job.progress(100, "Complete!")
|
|
361
|
+
return {"processed": True, "steps": total_steps}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
# If run directly, use the example handler
|
|
366
|
+
run_job(example_handler)
|
|
@@ -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.5.1",
|
|
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",
|