directops-operator 0.1.9__py3-none-any.whl

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.
agent/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Operator — DOPS client that connects to the Director and executes tasks."""
2
+
3
+ from .client import OperatorClient
4
+ from .executor import TaskExecutor
5
+
6
+ __all__ = ["OperatorClient", "TaskExecutor"]
agent/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """Operator CLI entry point.
2
+
3
+ Usage:
4
+ python -m agent.cli --host 65.108.215.205 --port 9000
5
+
6
+ No API key required on the Operator machine — the Director handles all model
7
+ calls (Claude for planning, Devstral for implementation). See DIRECTIVE.md AD-1.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import socket
17
+ import sys
18
+
19
+
20
+ def _parse_args() -> argparse.Namespace:
21
+ parser = argparse.ArgumentParser(
22
+ prog="directops-operator",
23
+ description="DirectOps Operator — connects to the Director and executes tasks.",
24
+ )
25
+ parser.add_argument(
26
+ "--url",
27
+ default=os.environ.get("DIRECTOR_URL", ""),
28
+ help="Director WebSocket URL for production use, e.g. wss://director.directops.io/ws/operator "
29
+ "(default: $DIRECTOR_URL). When set, overrides --host/--port.",
30
+ )
31
+ parser.add_argument(
32
+ "--host",
33
+ default=os.environ.get("DIRECTOR_HOST", "127.0.0.1"),
34
+ help="Director host for TCP mode (default: $DIRECTOR_HOST or 127.0.0.1)",
35
+ )
36
+ parser.add_argument(
37
+ "--port",
38
+ type=int,
39
+ default=int(os.environ.get("DIRECTOR_PORT", "9000")),
40
+ help="Director port for TCP mode (default: $DIRECTOR_PORT or 9000)",
41
+ )
42
+ parser.add_argument(
43
+ "--operator-id",
44
+ default="",
45
+ help="Stable operator ID (default: hostname-based)",
46
+ )
47
+ parser.add_argument(
48
+ "--name",
49
+ default="",
50
+ help="Display name for this machine in the dashboard (default: hostname)",
51
+ )
52
+ parser.add_argument(
53
+ "--model",
54
+ default="devstral-small-latest",
55
+ help="Model name to advertise on registration (informational only; no local API call)",
56
+ )
57
+ parser.add_argument(
58
+ "--capabilities",
59
+ nargs="+",
60
+ default=["code", "shell", "search"],
61
+ metavar="CAP",
62
+ help="Task types this operator handles (default: code shell search)",
63
+ )
64
+ parser.add_argument(
65
+ "--token",
66
+ default=os.environ.get("OPERATOR_TOKEN", ""),
67
+ help="Auth token issued by the Director (default: $OPERATOR_TOKEN)",
68
+ )
69
+ parser.add_argument(
70
+ "--workdir",
71
+ default=None,
72
+ help="Working directory for task execution (default: ~/directops-workspace)",
73
+ )
74
+ tls_grp = parser.add_argument_group("TLS")
75
+ tls_grp.add_argument(
76
+ "--no-tls",
77
+ action="store_true",
78
+ help="Disable TLS (insecure — for local testing only)",
79
+ )
80
+ tls_grp.add_argument(
81
+ "--ca-cert",
82
+ default=os.environ.get("DIRECTOR_CA_CERT"),
83
+ metavar="PATH",
84
+ help="Custom CA certificate for Director verification (default: system trust store)",
85
+ )
86
+ parser.add_argument(
87
+ "--log-level",
88
+ default="INFO",
89
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
90
+ help="Log verbosity (default: INFO)",
91
+ )
92
+ return parser.parse_args()
93
+
94
+
95
+ async def _run(args: argparse.Namespace) -> None:
96
+ # Imports here so the module is importable without heavy deps at load time
97
+ from .client import OperatorClient
98
+ from .executor import TaskExecutor
99
+ from .discovery import load_or_run
100
+ from .updater import check_and_maybe_update
101
+
102
+ check_and_maybe_update()
103
+
104
+ system_context = load_or_run()
105
+
106
+ executor = TaskExecutor(workdir=args.workdir)
107
+ client = OperatorClient(
108
+ director_url=args.url,
109
+ director_host=args.host,
110
+ director_port=args.port,
111
+ operator_id=args.operator_id,
112
+ capabilities=args.capabilities,
113
+ executor=executor,
114
+ model=args.model,
115
+ auth_token=args.token,
116
+ tls=not args.no_tls,
117
+ ca_cert=args.ca_cert,
118
+ display_name=args.name,
119
+ system_context=system_context,
120
+ )
121
+
122
+ if args.url:
123
+ logging.info("Starting operator %s → %s (WebSocket)", client.operator_id, args.url)
124
+ else:
125
+ logging.info("Starting operator %s → %s:%d (TCP)", client.operator_id, args.host, args.port)
126
+
127
+ try:
128
+ await client.run()
129
+ except KeyboardInterrupt:
130
+ await client.stop()
131
+
132
+
133
+ def main() -> None:
134
+ args = _parse_args()
135
+ logging.basicConfig(
136
+ level=getattr(logging, args.log_level),
137
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
138
+ )
139
+ try:
140
+ asyncio.run(_run(args))
141
+ except KeyboardInterrupt:
142
+ sys.exit(0)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
agent/client.py ADDED
@@ -0,0 +1,423 @@
1
+ """OperatorClient — asyncio client implementing the DOPS protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import platform
8
+ import socket
9
+ import ssl
10
+ import time
11
+ from typing import Any
12
+
13
+ from protocol.constants import MessageType, OperatorState
14
+ from protocol.messages import (
15
+ RegisterMessage,
16
+ TaskCompleteMessage,
17
+ TaskErrorMessage,
18
+ TaskUpdateMessage,
19
+ HeartbeatMessage,
20
+ AckMessage,
21
+ EventMessage,
22
+ TaskNeedsPasswordMessage,
23
+ deserialize,
24
+ )
25
+ from protocol.state import ConnectionStateMachine
26
+ from protocol.transport import DopsTransport
27
+
28
+ from .executor import TaskExecutor # noqa: F401 (re-exported)
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ HEARTBEAT_INTERVAL = 15.0 # seconds between keep-alive pings
33
+ RECONNECT_DELAY = 5.0 # seconds to wait before reconnecting
34
+
35
+
36
+ class OperatorClient:
37
+ """Manages the connection from an Operator to the Director.
38
+
39
+ Supports two transport modes:
40
+ - WebSocket (production): pass director_url="wss://director.directops.io/ws/operator"
41
+ - TCP (local dev): pass director_host and director_port
42
+
43
+ Args:
44
+ director_url: WebSocket URL for production use (overrides host/port).
45
+ director_host: IP or hostname of the Director server (TCP mode).
46
+ director_port: TCP port the Director listens on (TCP mode).
47
+ operator_id: Stable identifier for this operator instance.
48
+ capabilities: List of task-type tags this operator handles.
49
+ executor: TaskExecutor instance for running assigned tasks.
50
+ model: Local model name to advertise on registration.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ director_host: str = "",
56
+ director_port: int = 9000,
57
+ director_url: str = "",
58
+ operator_id: str = "",
59
+ capabilities: list[str] | None = None,
60
+ executor: TaskExecutor | None = None,
61
+ model: str = "devstral-small-latest",
62
+ auth_token: str = "",
63
+ tls: bool = True,
64
+ ca_cert: str | None = None,
65
+ display_name: str = "",
66
+ system_context: dict | None = None,
67
+ ) -> None:
68
+ self.host = director_host
69
+ self.port = director_port
70
+ self.director_url = director_url
71
+ self.operator_id = operator_id or f"{socket.gethostname()}-{id(self) & 0xFFFF:04x}"
72
+ self.capabilities = capabilities or ["code", "shell", "search"]
73
+ self.executor = executor or TaskExecutor()
74
+ self.model = model
75
+ self.auth_token = auth_token
76
+ self.tls = tls
77
+ self.ca_cert = ca_cert
78
+ self.display_name = display_name
79
+ self.system_context = system_context or {}
80
+
81
+ self._state = ConnectionStateMachine(
82
+ on_transition=self._on_state_change
83
+ )
84
+ self._transport: DopsTransport | None = None
85
+ self._running = False
86
+ self._current_task_id: str | None = None
87
+ # Futures waiting for PROVIDE_PASSWORD frames, keyed by task_id
88
+ self._password_futures: dict[str, asyncio.Future] = {}
89
+ # Background task for the currently executing task (so the receive loop stays live)
90
+ self._active_task: asyncio.Task | None = None
91
+
92
+ # ------------------------------------------------------------------
93
+ # Public API
94
+ # ------------------------------------------------------------------
95
+
96
+ async def run(self) -> None:
97
+ """Start the operator event loop. Reconnects on disconnect."""
98
+ self._running = True
99
+ while self._running:
100
+ try:
101
+ await self._connect_and_serve()
102
+ except asyncio.CancelledError:
103
+ break
104
+ except BaseException as exc:
105
+ if hasattr(exc, 'exceptions'):
106
+ for sub in exc.exceptions:
107
+ logger.error("Connection sub-error [%s]: %s", type(sub).__name__, sub, exc_info=sub)
108
+ else:
109
+ logger.error("Connection error [%s]: %s", type(exc).__name__, exc)
110
+ self._state.force(OperatorState.DISCONNECTED)
111
+ await asyncio.sleep(RECONNECT_DELAY)
112
+
113
+ async def stop(self) -> None:
114
+ """Gracefully shut down the operator."""
115
+ self._running = False
116
+ if self._active_task and not self._active_task.done():
117
+ self._active_task.cancel()
118
+ if self._transport:
119
+ await self._transport.close()
120
+
121
+ # ------------------------------------------------------------------
122
+ # Internal: connection lifecycle
123
+ # ------------------------------------------------------------------
124
+
125
+ async def _connect_and_serve(self) -> None:
126
+ if self.director_url:
127
+ await self._connect_ws()
128
+ else:
129
+ await self._connect_tcp()
130
+
131
+ async def _connect_tcp(self) -> None:
132
+ """Open a raw TCP connection to the Director."""
133
+ from protocol.tcp_transport import TcpTransport
134
+
135
+ ssl_context: ssl.SSLContext | None = None
136
+ if self.tls:
137
+ ssl_context = ssl.create_default_context()
138
+ if self.ca_cert:
139
+ ssl_context.load_verify_locations(self.ca_cert)
140
+ else:
141
+ logger.warning("TLS disabled — connection to Director is unencrypted")
142
+
143
+ logger.info(
144
+ "Connecting to Director at %s:%d (%s)",
145
+ self.host, self.port, "TLS" if ssl_context else "plaintext",
146
+ )
147
+ reader, writer = await asyncio.open_connection(
148
+ self.host, self.port, ssl=ssl_context
149
+ )
150
+ self._transport = TcpTransport(reader, writer)
151
+ self._state.force(OperatorState.CONNECTED)
152
+ logger.info("Connected (TCP). Sending REGISTER…")
153
+ await self._serve()
154
+
155
+ async def _connect_ws(self) -> None:
156
+ """Connect to the Director via WebSocket (port 443 compatible)."""
157
+ import websockets
158
+ from .ws_transport import ClientWebSocketTransport
159
+
160
+ logger.info("Connecting to Director at %s (WebSocket)", self.director_url)
161
+ async with websockets.connect(self.director_url) as ws:
162
+ self._transport = ClientWebSocketTransport(ws)
163
+ self._state.force(OperatorState.CONNECTED)
164
+ logger.info("Connected (WebSocket). Sending REGISTER…")
165
+ await self._serve()
166
+
167
+ async def _serve(self) -> None:
168
+ """Common post-connect logic: register then run recv + heartbeat loops."""
169
+ await self._send_register()
170
+ async with asyncio.TaskGroup() as tg:
171
+ tg.create_task(self._receive_loop())
172
+ tg.create_task(self._heartbeat_loop())
173
+
174
+ async def _send_register(self) -> None:
175
+ hostname = socket.gethostname()
176
+ msg = RegisterMessage(
177
+ operator_id=self.operator_id,
178
+ capabilities=self.capabilities,
179
+ model=self.model,
180
+ hostname=hostname,
181
+ auth_token=self.auth_token,
182
+ os_info=f"{platform.system()} {platform.release()}",
183
+ display_name=self.display_name or hostname,
184
+ system_context=self.system_context,
185
+ )
186
+ await self._transport.send_frame(MessageType.REGISTER, msg.to_bytes())
187
+
188
+ # ------------------------------------------------------------------
189
+ # Internal: receive loop
190
+ # ------------------------------------------------------------------
191
+
192
+ async def _receive_loop(self) -> None:
193
+ """Read frames from the Director and dispatch them."""
194
+ while self._running:
195
+ frame = await self._transport.recv_frame()
196
+ await self._dispatch(frame.msg_type, frame.payload)
197
+
198
+ async def _dispatch(self, msg_type: MessageType, payload: bytes) -> None:
199
+ """Route an incoming frame to the appropriate handler."""
200
+ msg = deserialize(msg_type, payload)
201
+
202
+ match msg_type:
203
+ case MessageType.REGISTER_ACK:
204
+ self._state.transition(MessageType.REGISTER)
205
+ logger.info("Registered with Director. Operator ID: %s", self.operator_id)
206
+ await self._transport.send_frame(
207
+ MessageType.ACK,
208
+ AckMessage(ack_id=msg.message_id).to_bytes(),
209
+ )
210
+
211
+ case MessageType.TASK_ASSIGN:
212
+ # Spawn as a background task so the receive loop stays live.
213
+ # This is required for PROVIDE_PASSWORD frames to be processed
214
+ # while a task is blocked waiting for a credential.
215
+ self._active_task = asyncio.create_task(self._handle_task_assign(msg))
216
+
217
+ case MessageType.HEARTBEAT:
218
+ await self._transport.send_frame(
219
+ MessageType.HEARTBEAT_ACK,
220
+ HeartbeatMessage(timestamp=time.time()).to_bytes(),
221
+ )
222
+
223
+ case MessageType.HEARTBEAT_ACK:
224
+ pass # pong received, nothing to do
225
+
226
+ case MessageType.ACK:
227
+ pass
228
+
229
+ case MessageType.NACK:
230
+ reason = getattr(msg, "reason", "")
231
+ logger.error("Director rejected registration: %s", reason)
232
+ self._running = False
233
+ raise ConnectionError(f"Registration rejected: {reason}")
234
+
235
+ case MessageType.PROVIDE_PASSWORD:
236
+ await self._handle_provide_password(msg)
237
+
238
+ case _:
239
+ logger.warning("Unhandled message type: %s", msg_type.name)
240
+
241
+ # ------------------------------------------------------------------
242
+ # Internal: task execution
243
+ # ------------------------------------------------------------------
244
+
245
+ async def _handle_task_assign(self, msg: Any) -> None:
246
+ """Execute an assigned task and report results back to the Director."""
247
+ self._state.transition(MessageType.TASK_ASSIGN)
248
+ self._current_task_id = msg.task_id
249
+ logger.info("Task assigned: %s (%s)", msg.task_id, msg.task_type)
250
+
251
+ # Acknowledge receipt
252
+ await self._transport.send_frame(
253
+ MessageType.ACK,
254
+ AckMessage(ack_id=msg.message_id).to_bytes(),
255
+ )
256
+
257
+ # Run with a timeout guard
258
+ try:
259
+ async with asyncio.timeout(msg.timeout_seconds):
260
+ result = await self._execute_with_updates(msg)
261
+ except TimeoutError:
262
+ await self._send_error(msg.task_id, "TIMEOUT", "Task exceeded timeout", retryable=True)
263
+ return
264
+ except Exception as exc:
265
+ await self._send_error(msg.task_id, "EXECUTOR_CRASH", str(exc), retryable=False)
266
+ return
267
+
268
+ if result.success:
269
+ await self._send_complete(msg.task_id, result)
270
+ self._state.transition(MessageType.TASK_COMPLETE)
271
+ else:
272
+ await self._send_error(
273
+ msg.task_id,
274
+ result.error_code,
275
+ result.error_detail,
276
+ retryable=True,
277
+ )
278
+
279
+ self._current_task_id = None
280
+
281
+ async def _execute_with_updates(self, msg: Any):
282
+ """Run the executor while sending a mid-task progress update."""
283
+ await self._send_update(msg.task_id, 0, "Starting…")
284
+
285
+ task_id = msg.task_id
286
+
287
+ async def event_fn(event_type: str, data: dict) -> None:
288
+ await self._emit_event(event_type, task_id, data)
289
+
290
+ async def password_fn(tid: str, prompt: str) -> str | None:
291
+ """Request a credential from the Director, return plaintext or None on timeout/deny."""
292
+ if not self._transport or self._transport.is_closing:
293
+ return None
294
+ needs_msg = TaskNeedsPasswordMessage(task_id=tid, prompt=prompt)
295
+ await self._transport.send_frame(MessageType.TASK_NEEDS_PASSWORD, needs_msg.to_bytes())
296
+ logger.info("Credential requested for task %s: %s", tid, prompt)
297
+
298
+ fut: asyncio.Future = asyncio.get_event_loop().create_future()
299
+ self._password_futures[tid] = fut
300
+ try:
301
+ # Wait slightly longer than the server's 120s timeout
302
+ return await asyncio.wait_for(fut, timeout=130.0)
303
+ except asyncio.TimeoutError:
304
+ logger.warning("Credential request timed out for task %s", tid)
305
+ return None
306
+ finally:
307
+ self._password_futures.pop(tid, None)
308
+
309
+ result = await self.executor.execute(
310
+ task_type=msg.task_type,
311
+ instruction=msg.instruction,
312
+ context=msg.context,
313
+ event_fn=event_fn,
314
+ password_fn=password_fn,
315
+ task_id=task_id,
316
+ )
317
+
318
+ await self._send_update(msg.task_id, 100, "Done")
319
+ return result
320
+
321
+ async def _handle_provide_password(self, msg: Any) -> None:
322
+ """Decrypt an incoming PROVIDE_PASSWORD frame and resolve the waiting future."""
323
+ from protocol.crypto import decrypt_password
324
+
325
+ encrypted = {"nonce": msg.nonce, "ciphertext": msg.ciphertext, "tag": msg.tag}
326
+ try:
327
+ password = decrypt_password(encrypted, self.auth_token)
328
+ except Exception as exc:
329
+ logger.error("Failed to decrypt credential for task %s: %s", msg.task_id, exc)
330
+ fut = self._password_futures.get(msg.task_id)
331
+ if fut and not fut.done():
332
+ fut.set_exception(exc)
333
+ return
334
+
335
+ fut = self._password_futures.get(msg.task_id)
336
+ if fut and not fut.done():
337
+ fut.set_result(password)
338
+ logger.info("Credential decrypted and delivered for task %s", msg.task_id)
339
+ else:
340
+ logger.warning("No pending credential future for task %s", msg.task_id)
341
+ # Don't hold the plaintext any longer than necessary
342
+ password = None # noqa: F841
343
+
344
+ async def _send_update(self, task_id: str, progress: int, status: str) -> None:
345
+ self._state.transition(MessageType.TASK_UPDATE)
346
+ msg = TaskUpdateMessage(task_id=task_id, progress=progress, status_text=status)
347
+ await self._transport.send_frame(MessageType.TASK_UPDATE, msg.to_bytes())
348
+
349
+ async def _send_complete(self, task_id: str, result) -> None:
350
+ msg = TaskCompleteMessage(
351
+ task_id=task_id,
352
+ result=result.artifacts,
353
+ duration_seconds=result.duration_seconds,
354
+ )
355
+ await self._transport.send_frame(MessageType.TASK_COMPLETE, msg.to_bytes())
356
+ logger.info("Task %s completed in %.2fs", task_id, result.duration_seconds)
357
+ await self._emit_event("task_executed", task_id, {
358
+ "status": "success",
359
+ "duration_seconds": result.duration_seconds,
360
+ "output_preview": result.output[:200] if result.output else "",
361
+ })
362
+
363
+ async def _send_error(
364
+ self, task_id: str, error_code: str, detail: str, retryable: bool
365
+ ) -> None:
366
+ msg = TaskErrorMessage(
367
+ task_id=task_id,
368
+ error_code=error_code,
369
+ error_detail=detail,
370
+ retryable=retryable,
371
+ )
372
+ await self._transport.send_frame(MessageType.TASK_ERROR, msg.to_bytes())
373
+ logger.error("Task %s failed [%s]: %s", task_id, error_code, detail)
374
+ self._state.transition(MessageType.TASK_ERROR)
375
+ await self._emit_event("command_failed", task_id, {
376
+ "error_code": error_code,
377
+ "detail": detail[:200],
378
+ "retryable": retryable,
379
+ })
380
+
381
+ async def _emit_event(self, event_type: str, task_id: str, payload: dict) -> None:
382
+ import json
383
+ if not self._transport or self._transport.is_closing:
384
+ return
385
+ try:
386
+ ev = EventMessage(
387
+ event_type=event_type,
388
+ task_id=task_id,
389
+ payload=json.dumps(payload),
390
+ timestamp=time.time(),
391
+ )
392
+ await self._transport.send_frame(MessageType.EVENT, ev.to_bytes())
393
+ except Exception:
394
+ pass # fire-and-forget: never let telemetry break task flow
395
+
396
+ # ------------------------------------------------------------------
397
+ # Internal: heartbeat loop
398
+ # ------------------------------------------------------------------
399
+
400
+ async def _heartbeat_loop(self) -> None:
401
+ """Send periodic HEARTBEAT frames to keep the connection alive."""
402
+ while self._running:
403
+ await asyncio.sleep(HEARTBEAT_INTERVAL)
404
+ try:
405
+ if self._transport and not self._transport.is_closing:
406
+ await self._transport.send_frame(
407
+ MessageType.HEARTBEAT,
408
+ HeartbeatMessage(timestamp=time.time()).to_bytes(),
409
+ )
410
+ except Exception as exc:
411
+ logger.error("Heartbeat failed [%s]: %s", type(exc).__name__, exc)
412
+ raise
413
+
414
+ # ------------------------------------------------------------------
415
+ # Internal: state change hook
416
+ # ------------------------------------------------------------------
417
+
418
+ def _on_state_change(
419
+ self, old: OperatorState, new: OperatorState, trigger: MessageType
420
+ ) -> None:
421
+ logger.debug(
422
+ "State: %s → %s (trigger: %s)", old.name, new.name, trigger.name
423
+ )