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 +6 -0
- agent/cli.py +146 -0
- agent/client.py +423 -0
- agent/discovery.py +229 -0
- agent/dops.py +408 -0
- agent/executor.py +388 -0
- agent/updater.py +182 -0
- agent/ws_transport.py +52 -0
- directops_operator-0.1.9.dist-info/METADATA +66 -0
- directops_operator-0.1.9.dist-info/RECORD +22 -0
- directops_operator-0.1.9.dist-info/WHEEL +5 -0
- directops_operator-0.1.9.dist-info/entry_points.txt +4 -0
- directops_operator-0.1.9.dist-info/top_level.txt +2 -0
- protocol/__init__.py +33 -0
- protocol/constants.py +36 -0
- protocol/crypto.py +46 -0
- protocol/frames.py +159 -0
- protocol/messages.py +271 -0
- protocol/state.py +99 -0
- protocol/tcp_transport.py +37 -0
- protocol/transport.py +29 -0
- protocol/ws_transport.py +46 -0
agent/__init__.py
ADDED
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
|
+
)
|