tactus 0.32.2__py3-none-any.whl → 0.34.0__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.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +382 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/server.py +300 -9
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
- {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
- {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
- {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host app control channel base class.
|
|
3
|
+
|
|
4
|
+
Provides the pattern for any app embedding Tactus to become a control channel.
|
|
5
|
+
The CLI is the simplest example, but this applies to any host app: web servers,
|
|
6
|
+
desktop apps, Jupyter notebooks, etc.
|
|
7
|
+
|
|
8
|
+
Key features:
|
|
9
|
+
- Interruptible via background thread pattern
|
|
10
|
+
- Can be cancelled if another channel responds first
|
|
11
|
+
- Races with remote channels - first response wins
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import threading
|
|
17
|
+
from abc import abstractmethod
|
|
18
|
+
from typing import Optional
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
|
|
21
|
+
from tactus.protocols.control import (
|
|
22
|
+
ControlRequest,
|
|
23
|
+
ControlResponse,
|
|
24
|
+
ChannelCapabilities,
|
|
25
|
+
DeliveryResult,
|
|
26
|
+
)
|
|
27
|
+
from tactus.adapters.channels.base import InProcessChannel
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HostControlChannel(InProcessChannel):
|
|
33
|
+
"""
|
|
34
|
+
Base class for host app control channels.
|
|
35
|
+
|
|
36
|
+
Any app embedding Tactus can extend this to become a control channel.
|
|
37
|
+
The channel uses a background thread for input collection so it can
|
|
38
|
+
be interrupted if another channel responds first.
|
|
39
|
+
|
|
40
|
+
Subclasses must implement:
|
|
41
|
+
- _prompt_for_input(): Display prompt and collect input (runs in thread)
|
|
42
|
+
- _show_cancelled(): Display cancellation message
|
|
43
|
+
|
|
44
|
+
The background thread pattern:
|
|
45
|
+
1. send() displays the request and starts a background thread
|
|
46
|
+
2. Thread calls _prompt_for_input() which blocks on user input
|
|
47
|
+
3. If input received, push_response() adds to queue
|
|
48
|
+
4. If cancel() called first, thread is interrupted via _cancel_event
|
|
49
|
+
5. _show_cancelled() displays "Responded via {channel}" message
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
"""Initialize the host channel."""
|
|
54
|
+
super().__init__()
|
|
55
|
+
self._cancel_event = threading.Event()
|
|
56
|
+
self._input_thread: Optional[threading.Thread] = None
|
|
57
|
+
self._current_request: Optional[ControlRequest] = None
|
|
58
|
+
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def capabilities(self) -> ChannelCapabilities:
|
|
62
|
+
"""Host channels support immediate synchronous responses."""
|
|
63
|
+
return ChannelCapabilities(
|
|
64
|
+
supports_approval=True,
|
|
65
|
+
supports_input=True,
|
|
66
|
+
supports_review=True,
|
|
67
|
+
supports_escalation=True,
|
|
68
|
+
supports_interactive_buttons=False,
|
|
69
|
+
supports_file_attachments=False,
|
|
70
|
+
max_message_length=None,
|
|
71
|
+
is_synchronous=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
75
|
+
"""
|
|
76
|
+
Display the request and start background input collection.
|
|
77
|
+
|
|
78
|
+
The actual prompt is displayed and input collected in a background
|
|
79
|
+
thread so we can be interrupted if another channel responds first.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
request: ControlRequest with full context
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
DeliveryResult indicating successful delivery
|
|
86
|
+
"""
|
|
87
|
+
logger.info(f"{self.channel_id}: sending notification for {request.request_id}")
|
|
88
|
+
|
|
89
|
+
# Store for background thread access
|
|
90
|
+
self._current_request = request
|
|
91
|
+
self._cancel_event.clear()
|
|
92
|
+
|
|
93
|
+
# Capture event loop for thread-safe response pushing
|
|
94
|
+
self._event_loop = asyncio.get_event_loop()
|
|
95
|
+
|
|
96
|
+
# Display the request (synchronous, before starting thread)
|
|
97
|
+
self._display_request(request)
|
|
98
|
+
|
|
99
|
+
# Start background thread for input collection
|
|
100
|
+
self._input_thread = threading.Thread(
|
|
101
|
+
target=self._input_thread_main,
|
|
102
|
+
args=(request,),
|
|
103
|
+
daemon=True,
|
|
104
|
+
)
|
|
105
|
+
self._input_thread.start()
|
|
106
|
+
|
|
107
|
+
return DeliveryResult(
|
|
108
|
+
channel_id=self.channel_id,
|
|
109
|
+
external_message_id=request.request_id,
|
|
110
|
+
delivered_at=datetime.now(timezone.utc),
|
|
111
|
+
success=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def cancel(self, external_message_id: str, reason: str) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Cancel the prompt - another channel responded first.
|
|
117
|
+
|
|
118
|
+
Sets the cancel event to interrupt the background thread,
|
|
119
|
+
then displays a message indicating another channel responded.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
external_message_id: Request ID (same as sent)
|
|
123
|
+
reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
|
|
124
|
+
"""
|
|
125
|
+
logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
|
|
126
|
+
self._cancel_event.set()
|
|
127
|
+
self._show_cancelled(reason)
|
|
128
|
+
|
|
129
|
+
async def shutdown(self) -> None:
|
|
130
|
+
"""Clean shutdown - cancel any pending input."""
|
|
131
|
+
await super().shutdown()
|
|
132
|
+
self._cancel_event.set()
|
|
133
|
+
if self._input_thread and self._input_thread.is_alive():
|
|
134
|
+
self._input_thread.join(timeout=1.0)
|
|
135
|
+
|
|
136
|
+
def _input_thread_main(self, request: ControlRequest) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Background thread main function.
|
|
139
|
+
|
|
140
|
+
Collects input from the user and pushes the response to the queue.
|
|
141
|
+
Can be interrupted via _cancel_event.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
request: The control request to handle
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
# Collect input (may block)
|
|
148
|
+
response_value = self._prompt_for_input(request)
|
|
149
|
+
|
|
150
|
+
# Check if cancelled while waiting
|
|
151
|
+
if self._cancel_event.is_set():
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if response_value is not None:
|
|
155
|
+
# Create response and push to queue
|
|
156
|
+
response = ControlResponse(
|
|
157
|
+
request_id=request.request_id,
|
|
158
|
+
value=response_value,
|
|
159
|
+
responded_at=datetime.now(timezone.utc),
|
|
160
|
+
timed_out=False,
|
|
161
|
+
channel_id=self.channel_id,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Push thread-safe
|
|
165
|
+
if self._event_loop:
|
|
166
|
+
self.push_response_threadsafe(response, self._event_loop)
|
|
167
|
+
else:
|
|
168
|
+
self.push_response(response)
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
if not self._cancel_event.is_set():
|
|
172
|
+
logger.error(f"{self.channel_id}: input error: {e}")
|
|
173
|
+
|
|
174
|
+
@abstractmethod
|
|
175
|
+
def _display_request(self, request: ControlRequest) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Display the control request to the user.
|
|
178
|
+
|
|
179
|
+
Called synchronously before starting input thread.
|
|
180
|
+
Should display the message, options, context, etc.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
request: The control request to display
|
|
184
|
+
"""
|
|
185
|
+
...
|
|
186
|
+
|
|
187
|
+
@abstractmethod
|
|
188
|
+
def _prompt_for_input(self, request: ControlRequest) -> Optional[any]:
|
|
189
|
+
"""
|
|
190
|
+
Collect input from the user.
|
|
191
|
+
|
|
192
|
+
Runs in a background thread. Should check _cancel_event periodically
|
|
193
|
+
if blocking on long operations. Returns None if cancelled.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
request: The control request being handled
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The user's response value, or None if cancelled/interrupted
|
|
200
|
+
"""
|
|
201
|
+
...
|
|
202
|
+
|
|
203
|
+
@abstractmethod
|
|
204
|
+
def _show_cancelled(self, reason: str) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Show cancellation message.
|
|
207
|
+
|
|
208
|
+
Called when another channel responds first.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
|
|
212
|
+
"""
|
|
213
|
+
...
|
|
214
|
+
|
|
215
|
+
def is_cancelled(self) -> bool:
|
|
216
|
+
"""
|
|
217
|
+
Check if the current request has been cancelled.
|
|
218
|
+
|
|
219
|
+
Call this periodically from _prompt_for_input() if your input
|
|
220
|
+
method supports checking for interruption.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True if cancelled, False otherwise
|
|
224
|
+
"""
|
|
225
|
+
return self._cancel_event.is_set()
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IPC Control Channel - Unix socket communication for control loop.
|
|
3
|
+
|
|
4
|
+
This channel allows external control CLI apps to connect and respond to
|
|
5
|
+
control requests via Unix domain sockets using the broker protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
|
|
15
|
+
from tactus.broker.protocol import read_message, write_message
|
|
16
|
+
from tactus.protocols.control import (
|
|
17
|
+
ControlRequest,
|
|
18
|
+
ControlResponse,
|
|
19
|
+
ChannelCapabilities,
|
|
20
|
+
DeliveryResult,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IPCControlChannel:
|
|
27
|
+
"""
|
|
28
|
+
Control channel using Unix socket IPC with broker protocol.
|
|
29
|
+
|
|
30
|
+
The runtime creates a Unix socket server that control CLI clients can
|
|
31
|
+
connect to. Control requests are broadcast to all connected clients,
|
|
32
|
+
and the first response wins (standard racing pattern).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, socket_path: Optional[str] = None, procedure_id: Optional[str] = None):
|
|
36
|
+
"""
|
|
37
|
+
Initialize IPC control channel.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
socket_path: Path to Unix socket (default: /tmp/tactus-control-{procedure_id}.sock)
|
|
41
|
+
procedure_id: Procedure ID for default socket path
|
|
42
|
+
"""
|
|
43
|
+
self.procedure_id = procedure_id or "default"
|
|
44
|
+
self.socket_path = socket_path or f"/tmp/tactus-control-{self.procedure_id}.sock"
|
|
45
|
+
self.channel_id = "ipc"
|
|
46
|
+
|
|
47
|
+
self._server: Optional[asyncio.Server] = None
|
|
48
|
+
self._clients: Dict[str, asyncio.StreamWriter] = {} # client_id -> writer
|
|
49
|
+
self._response_queue: asyncio.Queue[ControlResponse] = asyncio.Queue()
|
|
50
|
+
self._pending_requests: Dict[str, ControlRequest] = {} # request_id -> request
|
|
51
|
+
self._initialized = False
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def capabilities(self) -> ChannelCapabilities:
|
|
55
|
+
"""IPC supports all request types and can respond synchronously."""
|
|
56
|
+
return ChannelCapabilities(
|
|
57
|
+
supports_approval=True,
|
|
58
|
+
supports_input=True,
|
|
59
|
+
supports_choice=True,
|
|
60
|
+
supports_review=True,
|
|
61
|
+
supports_escalation=True,
|
|
62
|
+
is_synchronous=True, # Humans respond in real-time via control CLI
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def initialize(self) -> None:
|
|
66
|
+
"""Start Unix socket server and accept connections."""
|
|
67
|
+
if self._initialized:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
logger.info(f"{self.channel_id}: initializing...")
|
|
71
|
+
|
|
72
|
+
# Remove old socket file if it exists
|
|
73
|
+
if os.path.exists(self.socket_path):
|
|
74
|
+
os.unlink(self.socket_path)
|
|
75
|
+
|
|
76
|
+
# Create parent directory if needed
|
|
77
|
+
socket_dir = os.path.dirname(self.socket_path)
|
|
78
|
+
os.makedirs(socket_dir, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
# Start Unix socket server
|
|
81
|
+
self._server = await asyncio.start_unix_server(self._handle_client, path=self.socket_path)
|
|
82
|
+
|
|
83
|
+
# Set socket permissions
|
|
84
|
+
os.chmod(self.socket_path, 0o600)
|
|
85
|
+
|
|
86
|
+
self._initialized = True
|
|
87
|
+
logger.info(f"{self.channel_id}: ready (listening on {self.socket_path})")
|
|
88
|
+
|
|
89
|
+
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
90
|
+
"""
|
|
91
|
+
Send control request to all connected clients.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
request: ControlRequest object with all request details
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
DeliveryResult with success/failure info
|
|
98
|
+
"""
|
|
99
|
+
logger.info(f"{self.channel_id}: sending notification for {request.request_id}")
|
|
100
|
+
|
|
101
|
+
# Create control request message from ControlRequest object
|
|
102
|
+
request_data = {
|
|
103
|
+
"type": "control.request",
|
|
104
|
+
"request_id": request.request_id,
|
|
105
|
+
"procedure_id": request.procedure_id,
|
|
106
|
+
"procedure_name": request.procedure_name,
|
|
107
|
+
"invocation_id": request.invocation_id,
|
|
108
|
+
"request_type": request.request_type,
|
|
109
|
+
"message": request.message,
|
|
110
|
+
"options": [{"label": opt.label, "value": opt.value} for opt in request.options],
|
|
111
|
+
"default_value": request.default_value,
|
|
112
|
+
"timeout_seconds": request.timeout_seconds,
|
|
113
|
+
"metadata": request.metadata,
|
|
114
|
+
"namespace": request.namespace,
|
|
115
|
+
"subject": request.subject,
|
|
116
|
+
"started_at": request.started_at.isoformat() if request.started_at else None,
|
|
117
|
+
"input_summary": request.input_summary,
|
|
118
|
+
"conversation": request.conversation,
|
|
119
|
+
"prior_interactions": request.prior_interactions,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Store pending request
|
|
123
|
+
self._pending_requests[request.request_id] = request_data
|
|
124
|
+
|
|
125
|
+
# Send to all connected clients
|
|
126
|
+
successful = 0
|
|
127
|
+
failed = 0
|
|
128
|
+
|
|
129
|
+
for client_id, writer in list(self._clients.items()):
|
|
130
|
+
try:
|
|
131
|
+
await write_message(writer, request_data)
|
|
132
|
+
successful += 1
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"{self.channel_id}: failed to send to client {client_id}: {e}")
|
|
135
|
+
failed += 1
|
|
136
|
+
# Remove dead client
|
|
137
|
+
self._clients.pop(client_id, None)
|
|
138
|
+
|
|
139
|
+
if successful == 0 and len(self._clients) == 0:
|
|
140
|
+
logger.warning(f"{self.channel_id}: no clients connected")
|
|
141
|
+
|
|
142
|
+
# Return DeliveryResult
|
|
143
|
+
return DeliveryResult(
|
|
144
|
+
channel_id=self.channel_id,
|
|
145
|
+
external_message_id=request.request_id,
|
|
146
|
+
delivered_at=datetime.now(),
|
|
147
|
+
success=successful > 0,
|
|
148
|
+
error_message=None if successful > 0 else "No clients connected",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def receive(self):
|
|
152
|
+
"""
|
|
153
|
+
Yield responses from clients as they arrive.
|
|
154
|
+
|
|
155
|
+
Yields:
|
|
156
|
+
ControlResponse objects
|
|
157
|
+
"""
|
|
158
|
+
while True:
|
|
159
|
+
response = await self._response_queue.get()
|
|
160
|
+
logger.info(f"{self.channel_id}: received response for {response.request_id}")
|
|
161
|
+
yield response
|
|
162
|
+
|
|
163
|
+
async def cancel(self, request_id: str, reason: str) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Cancel a pending request.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
request_id: Request to cancel
|
|
169
|
+
reason: Cancellation reason
|
|
170
|
+
"""
|
|
171
|
+
logger.debug(f"{self.channel_id}: cancelling {request_id} ({reason})")
|
|
172
|
+
|
|
173
|
+
# Remove from pending
|
|
174
|
+
self._pending_requests.pop(request_id, None)
|
|
175
|
+
|
|
176
|
+
# Send cancellation to all clients
|
|
177
|
+
cancel_message = {"type": "control.cancelled", "request_id": request_id, "reason": reason}
|
|
178
|
+
|
|
179
|
+
for client_id, writer in list(self._clients.items()):
|
|
180
|
+
try:
|
|
181
|
+
await write_message(writer, cancel_message)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"{self.channel_id}: failed to send cancellation to {client_id}: {e}")
|
|
184
|
+
|
|
185
|
+
async def shutdown(self) -> None:
|
|
186
|
+
"""Clean up and close server."""
|
|
187
|
+
logger.info(f"{self.channel_id}: shutting down")
|
|
188
|
+
|
|
189
|
+
# Close all client connections
|
|
190
|
+
for client_id, writer in list(self._clients.items()):
|
|
191
|
+
try:
|
|
192
|
+
writer.close()
|
|
193
|
+
await writer.wait_closed()
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.error(f"{self.channel_id}: error closing client {client_id}: {e}")
|
|
196
|
+
|
|
197
|
+
self._clients.clear()
|
|
198
|
+
|
|
199
|
+
# Close server
|
|
200
|
+
if self._server:
|
|
201
|
+
self._server.close()
|
|
202
|
+
await self._server.wait_closed()
|
|
203
|
+
|
|
204
|
+
# Remove socket file
|
|
205
|
+
if os.path.exists(self.socket_path):
|
|
206
|
+
try:
|
|
207
|
+
os.unlink(self.socket_path)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"{self.channel_id}: failed to remove socket file: {e}")
|
|
210
|
+
|
|
211
|
+
self._initialized = False
|
|
212
|
+
|
|
213
|
+
async def _handle_client(
|
|
214
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
215
|
+
) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Handle a connected client.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
reader: asyncio StreamReader
|
|
221
|
+
writer: asyncio StreamWriter
|
|
222
|
+
"""
|
|
223
|
+
client_id = str(uuid.uuid4())[:8]
|
|
224
|
+
|
|
225
|
+
logger.info(f"{self.channel_id}: client connected ({client_id})")
|
|
226
|
+
|
|
227
|
+
# Register client
|
|
228
|
+
self._clients[client_id] = writer
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
# Send any pending requests to the new client
|
|
232
|
+
for request_id, request_data in self._pending_requests.items():
|
|
233
|
+
try:
|
|
234
|
+
await write_message(writer, request_data)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(
|
|
237
|
+
f"{self.channel_id}: failed to send pending request to {client_id}: {e}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Read messages from client
|
|
241
|
+
while True:
|
|
242
|
+
try:
|
|
243
|
+
message = await read_message(reader)
|
|
244
|
+
except EOFError:
|
|
245
|
+
break
|
|
246
|
+
except asyncio.IncompleteReadError:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
# Handle message
|
|
250
|
+
msg_type = message.get("type")
|
|
251
|
+
|
|
252
|
+
if msg_type == "control.response":
|
|
253
|
+
# Parse response and queue it
|
|
254
|
+
response = ControlResponse(
|
|
255
|
+
request_id=message["request_id"],
|
|
256
|
+
value=message["value"],
|
|
257
|
+
responder_id=message.get("responder_id", client_id),
|
|
258
|
+
responded_at=(
|
|
259
|
+
datetime.fromisoformat(message["responded_at"])
|
|
260
|
+
if message.get("responded_at")
|
|
261
|
+
else datetime.now()
|
|
262
|
+
),
|
|
263
|
+
timed_out=message.get("timed_out", False),
|
|
264
|
+
channel_id=self.channel_id,
|
|
265
|
+
)
|
|
266
|
+
await self._response_queue.put(response)
|
|
267
|
+
logger.info(f"{self.channel_id}: received response for {response.request_id}")
|
|
268
|
+
|
|
269
|
+
# Remove from pending
|
|
270
|
+
self._pending_requests.pop(response.request_id, None)
|
|
271
|
+
|
|
272
|
+
elif msg_type == "control.list":
|
|
273
|
+
# Client requesting list of pending requests
|
|
274
|
+
list_response = {
|
|
275
|
+
"type": "control.list_response",
|
|
276
|
+
"requests": list(self._pending_requests.values()),
|
|
277
|
+
}
|
|
278
|
+
await write_message(writer, list_response)
|
|
279
|
+
|
|
280
|
+
else:
|
|
281
|
+
logger.warning(
|
|
282
|
+
f"{self.channel_id}: unknown message type from {client_id}: {msg_type}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.error(f"{self.channel_id}: error handling client {client_id}: {e}")
|
|
287
|
+
|
|
288
|
+
finally:
|
|
289
|
+
# Clean up
|
|
290
|
+
self._clients.pop(client_id, None)
|
|
291
|
+
logger.info(f"{self.channel_id}: client disconnected ({client_id})")
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
writer.close()
|
|
295
|
+
await writer.wait_closed()
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|