ferp 0.7.1__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.
- ferp/__init__.py +3 -0
- ferp/__main__.py +4 -0
- ferp/__version__.py +1 -0
- ferp/app.py +9 -0
- ferp/cli.py +160 -0
- ferp/core/__init__.py +0 -0
- ferp/core/app.py +1312 -0
- ferp/core/bundle_installer.py +245 -0
- ferp/core/command_provider.py +77 -0
- ferp/core/dependency_manager.py +59 -0
- ferp/core/fs_controller.py +70 -0
- ferp/core/fs_watcher.py +144 -0
- ferp/core/messages.py +49 -0
- ferp/core/path_actions.py +124 -0
- ferp/core/paths.py +3 -0
- ferp/core/protocols.py +8 -0
- ferp/core/script_controller.py +515 -0
- ferp/core/script_protocol.py +35 -0
- ferp/core/script_runner.py +421 -0
- ferp/core/settings.py +16 -0
- ferp/core/settings_store.py +69 -0
- ferp/core/state.py +156 -0
- ferp/core/task_store.py +164 -0
- ferp/core/transcript_logger.py +95 -0
- ferp/domain/__init__.py +0 -0
- ferp/domain/scripts.py +29 -0
- ferp/fscp/host/__init__.py +11 -0
- ferp/fscp/host/host.py +439 -0
- ferp/fscp/host/managed_process.py +113 -0
- ferp/fscp/host/process_registry.py +124 -0
- ferp/fscp/protocol/__init__.py +13 -0
- ferp/fscp/protocol/errors.py +2 -0
- ferp/fscp/protocol/messages.py +55 -0
- ferp/fscp/protocol/schemas/__init__.py +0 -0
- ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
- ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
- ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
- ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
- ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
- ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
- ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
- ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
- ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
- ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
- ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
- ferp/fscp/protocol/state.py +16 -0
- ferp/fscp/protocol/validator.py +123 -0
- ferp/fscp/scripts/__init__.py +0 -0
- ferp/fscp/scripts/runtime/__init__.py +4 -0
- ferp/fscp/scripts/runtime/__main__.py +40 -0
- ferp/fscp/scripts/runtime/errors.py +14 -0
- ferp/fscp/scripts/runtime/io.py +64 -0
- ferp/fscp/scripts/runtime/script.py +149 -0
- ferp/fscp/scripts/runtime/state.py +17 -0
- ferp/fscp/scripts/runtime/worker.py +13 -0
- ferp/fscp/scripts/sdk.py +548 -0
- ferp/fscp/transcript/__init__.py +3 -0
- ferp/fscp/transcript/events.py +14 -0
- ferp/resources/__init__.py +0 -0
- ferp/services/__init__.py +3 -0
- ferp/services/file_listing.py +120 -0
- ferp/services/monday_sync.py +155 -0
- ferp/services/releases.py +214 -0
- ferp/services/scripts.py +90 -0
- ferp/services/update_check.py +130 -0
- ferp/styles/index.tcss +638 -0
- ferp/themes/themes.py +238 -0
- ferp/widgets/__init__.py +17 -0
- ferp/widgets/dialogs.py +167 -0
- ferp/widgets/file_tree.py +991 -0
- ferp/widgets/forms.py +146 -0
- ferp/widgets/output_panel.py +244 -0
- ferp/widgets/panels.py +13 -0
- ferp/widgets/process_list.py +158 -0
- ferp/widgets/readme_modal.py +59 -0
- ferp/widgets/scripts.py +192 -0
- ferp/widgets/task_capture.py +74 -0
- ferp/widgets/task_list.py +493 -0
- ferp/widgets/top_bar.py +110 -0
- ferp-0.7.1.dist-info/METADATA +128 -0
- ferp-0.7.1.dist-info/RECORD +87 -0
- ferp-0.7.1.dist-info/WHEEL +5 -0
- ferp-0.7.1.dist-info/entry_points.txt +2 -0
- ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
- ferp-0.7.1.dist-info/top_level.txt +1 -0
ferp/fscp/host/host.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ferp.fscp.host.managed_process import ManagedProcess, WorkerFn
|
|
8
|
+
from ferp.fscp.host.process_registry import (
|
|
9
|
+
ProcessMetadata,
|
|
10
|
+
ProcessRegistry,
|
|
11
|
+
)
|
|
12
|
+
from ferp.fscp.protocol.messages import Message, MessageDirection, MessageType
|
|
13
|
+
from ferp.fscp.protocol.state import HostState
|
|
14
|
+
from ferp.fscp.protocol.validator import Endpoint, ProtocolValidator
|
|
15
|
+
from ferp.fscp.transcript.events import TranscriptEvent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Host:
|
|
19
|
+
"""
|
|
20
|
+
Authoritative ferp.fscp host.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
worker: WorkerFn,
|
|
26
|
+
timeout_ms: Optional[int] = None,
|
|
27
|
+
validator: Optional[ProtocolValidator] = None,
|
|
28
|
+
process_registry: ProcessRegistry | None = None,
|
|
29
|
+
process_metadata: ProcessMetadata | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.state: HostState = HostState.CREATED
|
|
32
|
+
self.process = ManagedProcess(worker=worker)
|
|
33
|
+
|
|
34
|
+
self.timeout_ms = timeout_ms
|
|
35
|
+
self.validator = validator or ProtocolValidator()
|
|
36
|
+
self._registry = process_registry
|
|
37
|
+
self._process_metadata = process_metadata
|
|
38
|
+
self._process_handle: str | None = None
|
|
39
|
+
|
|
40
|
+
self.transcript: list[TranscriptEvent] = []
|
|
41
|
+
self.results: list[dict] = []
|
|
42
|
+
|
|
43
|
+
self._start_time: Optional[float] = None
|
|
44
|
+
self._exit_seen = False
|
|
45
|
+
self._progress_updates: list[dict] = []
|
|
46
|
+
self._termination_mode: str | None = None
|
|
47
|
+
|
|
48
|
+
self._record_system("Host created")
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Lifecycle
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def start(self) -> None:
|
|
55
|
+
if self.state is not HostState.CREATED:
|
|
56
|
+
raise RuntimeError("Host can only be started from CREATED")
|
|
57
|
+
|
|
58
|
+
self.process.start()
|
|
59
|
+
self._register_process()
|
|
60
|
+
self._start_time = time.time()
|
|
61
|
+
|
|
62
|
+
self._transition(HostState.PROCESS_STARTED)
|
|
63
|
+
|
|
64
|
+
def poll(self) -> None:
|
|
65
|
+
if self.state in {
|
|
66
|
+
HostState.TERMINATED,
|
|
67
|
+
HostState.ERR_PROTOCOL,
|
|
68
|
+
HostState.ERR_TRANSPORT,
|
|
69
|
+
}:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
self._check_timeout()
|
|
73
|
+
|
|
74
|
+
conn = self.process.connection
|
|
75
|
+
if conn is not None:
|
|
76
|
+
# -------------------------
|
|
77
|
+
# Drain messages from the pipe
|
|
78
|
+
# -------------------------
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
has_data = conn.poll(0)
|
|
82
|
+
except (OSError, ValueError) as exc:
|
|
83
|
+
if self._exit_seen:
|
|
84
|
+
self._cleanup_connection()
|
|
85
|
+
break
|
|
86
|
+
self._fail_transport(f"Connection poll failed: {exc}")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
if not has_data:
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
payload = conn.recv()
|
|
94
|
+
except EOFError:
|
|
95
|
+
if self._exit_seen:
|
|
96
|
+
self._cleanup_connection()
|
|
97
|
+
break
|
|
98
|
+
if self.state is HostState.CANCELLING:
|
|
99
|
+
self._record_system("Script connection closed during cancellation")
|
|
100
|
+
self._cleanup_connection()
|
|
101
|
+
exit_code = self.process.poll_exit()
|
|
102
|
+
if exit_code is None:
|
|
103
|
+
self._termination_mode = self._termination_mode or "cancel"
|
|
104
|
+
self._record_exit(self.process.exit_code)
|
|
105
|
+
else:
|
|
106
|
+
if not self._exit_seen:
|
|
107
|
+
self._termination_mode = self._termination_mode or "cancel"
|
|
108
|
+
self._record_exit(exit_code)
|
|
109
|
+
self._transition(HostState.TERMINATED)
|
|
110
|
+
return
|
|
111
|
+
self._fail_transport("Script connection closed unexpectedly")
|
|
112
|
+
return
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
if self.state is HostState.CANCELLING:
|
|
115
|
+
self._record_system(f"Pipe read error during cancellation: {exc}")
|
|
116
|
+
self._cleanup_connection()
|
|
117
|
+
exit_code = self.process.poll_exit()
|
|
118
|
+
if exit_code is None:
|
|
119
|
+
self._termination_mode = self._termination_mode or "cancel"
|
|
120
|
+
self._record_exit(self.process.exit_code)
|
|
121
|
+
else:
|
|
122
|
+
if not self._exit_seen:
|
|
123
|
+
self._termination_mode = self._termination_mode or "cancel"
|
|
124
|
+
self._record_exit(exit_code)
|
|
125
|
+
self._transition(HostState.TERMINATED)
|
|
126
|
+
return
|
|
127
|
+
self._fail_transport(f"Pipe read error: {exc}")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if not isinstance(payload, dict):
|
|
131
|
+
self._record_incoming(raw=str(payload), msg=None)
|
|
132
|
+
self._fail_protocol("Invalid payload received from script")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
raw = json.dumps(payload)
|
|
136
|
+
try:
|
|
137
|
+
msg = Message.from_dict(payload)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
self._record_incoming(raw=raw, msg=None)
|
|
140
|
+
self._fail_protocol(f"Invalid ferp.fscp message: {exc}")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self.receive(msg, raw=raw)
|
|
144
|
+
|
|
145
|
+
# -------------------------
|
|
146
|
+
# Process exit detection
|
|
147
|
+
# -------------------------
|
|
148
|
+
exit_code = self.process.poll_exit()
|
|
149
|
+
if exit_code is None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._cleanup_connection()
|
|
153
|
+
|
|
154
|
+
if not self._exit_seen:
|
|
155
|
+
self._record_system("Process exited without exit message (abnormal)")
|
|
156
|
+
self._termination_mode = self._termination_mode or "abnormal-exit"
|
|
157
|
+
|
|
158
|
+
self._record_exit(exit_code)
|
|
159
|
+
self._transition(HostState.TERMINATED)
|
|
160
|
+
|
|
161
|
+
def shutdown(self, *, force: bool = False) -> None:
|
|
162
|
+
if self.state in {
|
|
163
|
+
HostState.TERMINATED,
|
|
164
|
+
HostState.ERR_PROTOCOL,
|
|
165
|
+
HostState.ERR_TRANSPORT,
|
|
166
|
+
}:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
self._record_system("Shutdown initiated")
|
|
170
|
+
self._transition(HostState.CANCELLING)
|
|
171
|
+
self._termination_mode = "kill" if force else "terminate"
|
|
172
|
+
|
|
173
|
+
if force:
|
|
174
|
+
self.process.kill()
|
|
175
|
+
else:
|
|
176
|
+
self.process.terminate()
|
|
177
|
+
self._cleanup_connection()
|
|
178
|
+
|
|
179
|
+
# Ensure the registry sees a terminal state even if we don't poll again.
|
|
180
|
+
self._record_exit(self.process.exit_code)
|
|
181
|
+
self._transition(HostState.TERMINATED)
|
|
182
|
+
|
|
183
|
+
def request_cancel(self) -> None:
|
|
184
|
+
if self.state in {
|
|
185
|
+
HostState.TERMINATED,
|
|
186
|
+
HostState.ERR_PROTOCOL,
|
|
187
|
+
HostState.ERR_TRANSPORT,
|
|
188
|
+
}:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
self._record_system("Cancellation requested")
|
|
192
|
+
self._termination_mode = self._termination_mode or "cancel"
|
|
193
|
+
msg = Message(type=MessageType.CANCEL, payload={})
|
|
194
|
+
self.send(msg)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Protocol IO
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def send(self, msg: Message) -> None:
|
|
201
|
+
self.validator.validate(msg, sender=Endpoint.HOST)
|
|
202
|
+
|
|
203
|
+
if msg.type is MessageType.INIT:
|
|
204
|
+
self._transition(HostState.INIT_SENT)
|
|
205
|
+
|
|
206
|
+
self._dispatch(msg)
|
|
207
|
+
self._record_outgoing(msg)
|
|
208
|
+
|
|
209
|
+
def receive(self, msg: Message, *, raw: Optional[str] = None) -> None:
|
|
210
|
+
self.validator.validate(msg, sender=Endpoint.SCRIPT)
|
|
211
|
+
self._handle_incoming(msg)
|
|
212
|
+
self._record_incoming(raw, msg)
|
|
213
|
+
|
|
214
|
+
def provide_input(self, payload: dict) -> None:
|
|
215
|
+
if self.state is not HostState.AWAITING_INPUT:
|
|
216
|
+
raise RuntimeError("No input is currently awaited")
|
|
217
|
+
|
|
218
|
+
msg = Message(type=MessageType.INPUT_RESPONSE, payload=payload)
|
|
219
|
+
self._transition(HostState.RUNNING)
|
|
220
|
+
self.send(msg)
|
|
221
|
+
|
|
222
|
+
def record_system(self, note: str) -> None:
|
|
223
|
+
"""Add a system note to the transcript."""
|
|
224
|
+
self._record_system(note)
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Internal helpers
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def _dispatch(self, msg: Message) -> None:
|
|
231
|
+
conn = self.process.connection
|
|
232
|
+
if conn is None:
|
|
233
|
+
raise RuntimeError("Process connection unavailable")
|
|
234
|
+
|
|
235
|
+
payload = msg.to_dict()
|
|
236
|
+
try:
|
|
237
|
+
conn.send(payload)
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
self._record_system(f"Pipe send failed: {exc}")
|
|
240
|
+
self._transition(HostState.ERR_TRANSPORT)
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
def _cleanup_connection(self) -> None:
|
|
244
|
+
conn = self.process.connection
|
|
245
|
+
if conn is None:
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
conn.close()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
finally:
|
|
252
|
+
self.process.connection = None
|
|
253
|
+
|
|
254
|
+
def _fail_transport(self, note: str) -> None:
|
|
255
|
+
if self.state not in {
|
|
256
|
+
HostState.TERMINATED,
|
|
257
|
+
HostState.ERR_PROTOCOL,
|
|
258
|
+
HostState.ERR_TRANSPORT,
|
|
259
|
+
}:
|
|
260
|
+
self._record_system(note)
|
|
261
|
+
self._transition(HostState.ERR_TRANSPORT)
|
|
262
|
+
self._termination_mode = self._termination_mode or "transport-error"
|
|
263
|
+
try:
|
|
264
|
+
self.process.kill()
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
self._cleanup_connection()
|
|
268
|
+
|
|
269
|
+
def _fail_protocol(self, note: str) -> None:
|
|
270
|
+
if self.state not in {
|
|
271
|
+
HostState.TERMINATED,
|
|
272
|
+
HostState.ERR_PROTOCOL,
|
|
273
|
+
HostState.ERR_TRANSPORT,
|
|
274
|
+
}:
|
|
275
|
+
self._record_system(note)
|
|
276
|
+
self._transition(HostState.ERR_PROTOCOL)
|
|
277
|
+
self._termination_mode = self._termination_mode or "protocol-error"
|
|
278
|
+
try:
|
|
279
|
+
self.process.kill()
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
self._cleanup_connection()
|
|
283
|
+
|
|
284
|
+
def _handle_incoming(self, msg: Message) -> None:
|
|
285
|
+
if self.state in {
|
|
286
|
+
HostState.TERMINATED,
|
|
287
|
+
HostState.ERR_PROTOCOL,
|
|
288
|
+
HostState.ERR_TRANSPORT,
|
|
289
|
+
}:
|
|
290
|
+
self._protocol_violation("Message received after termination")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if self.state is HostState.INIT_SENT:
|
|
294
|
+
self._transition(HostState.RUNNING)
|
|
295
|
+
|
|
296
|
+
match msg.type:
|
|
297
|
+
case MessageType.LOG:
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
case MessageType.PROGRESS:
|
|
301
|
+
payload = dict(msg.payload) if msg.payload else {}
|
|
302
|
+
self._progress_updates.append(payload)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
case MessageType.RESULT:
|
|
306
|
+
if self._exit_seen:
|
|
307
|
+
self._protocol_violation("Result after exit")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
self.results.append(dict(msg.payload))
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
case MessageType.REQUEST_INPUT:
|
|
314
|
+
if self.state is HostState.AWAITING_INPUT:
|
|
315
|
+
self._protocol_violation("Multiple outstanding input requests")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if self.state is not HostState.RUNNING:
|
|
319
|
+
self._protocol_violation(
|
|
320
|
+
f"'request_input' not allowed in state {self.state.name}"
|
|
321
|
+
)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
self._transition(HostState.AWAITING_INPUT)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
case MessageType.EXIT:
|
|
328
|
+
if self._exit_seen:
|
|
329
|
+
self._protocol_violation("Duplicate exit")
|
|
330
|
+
return
|
|
331
|
+
self._exit_seen = True
|
|
332
|
+
self._record_system(f"Exit received: {msg.payload}")
|
|
333
|
+
self._termination_mode = self._termination_mode or "exit"
|
|
334
|
+
self._transition(HostState.EXIT_RECEIVED)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
case _:
|
|
338
|
+
self._protocol_violation(f"Unhandled message: {msg.type.value}")
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
def _protocol_violation(self, reason: str) -> None:
|
|
342
|
+
self._fail_protocol(f"Protocol violation: {reason}")
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# State + timeout
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def _transition(self, new_state: HostState) -> None:
|
|
349
|
+
self._record_system(f"State {self.state.name} → {new_state.name}")
|
|
350
|
+
self.state = new_state
|
|
351
|
+
self._update_registry_state()
|
|
352
|
+
|
|
353
|
+
def _check_timeout(self) -> None:
|
|
354
|
+
if self.timeout_ms is None or self._start_time is None:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
elapsed_ms = (time.time() - self._start_time) * 1000
|
|
358
|
+
if elapsed_ms > self.timeout_ms:
|
|
359
|
+
self._record_system("Execution timeout")
|
|
360
|
+
self.shutdown(force=True)
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------
|
|
363
|
+
# Transcript
|
|
364
|
+
# ------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def _record_incoming(
|
|
367
|
+
self,
|
|
368
|
+
raw: Optional[str],
|
|
369
|
+
msg: Optional[Message],
|
|
370
|
+
) -> None:
|
|
371
|
+
self.transcript.append(
|
|
372
|
+
TranscriptEvent(
|
|
373
|
+
timestamp=time.time(),
|
|
374
|
+
direction=MessageDirection.RECV,
|
|
375
|
+
raw=raw,
|
|
376
|
+
message=msg,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _record_outgoing(self, msg: Message) -> None:
|
|
381
|
+
self.transcript.append(
|
|
382
|
+
TranscriptEvent(
|
|
383
|
+
timestamp=time.time(),
|
|
384
|
+
direction=MessageDirection.SEND,
|
|
385
|
+
message=msg,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _record_system(self, note: str) -> None:
|
|
390
|
+
self.transcript.append(
|
|
391
|
+
TranscriptEvent(
|
|
392
|
+
timestamp=time.time(),
|
|
393
|
+
direction=MessageDirection.INTERNAL,
|
|
394
|
+
raw=note,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def drain_progress_updates(self) -> list[dict]:
|
|
399
|
+
updates = self._progress_updates
|
|
400
|
+
self._progress_updates = []
|
|
401
|
+
return updates
|
|
402
|
+
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
# Process registry helpers
|
|
405
|
+
# ------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def process_handle(self) -> str | None:
|
|
409
|
+
return self._process_handle
|
|
410
|
+
|
|
411
|
+
def _register_process(self) -> None:
|
|
412
|
+
if self._registry is None:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
pid = self.process.process.pid if self.process.process else None
|
|
416
|
+
metadata = self._process_metadata
|
|
417
|
+
if metadata is None:
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
record = self._registry.register(
|
|
421
|
+
metadata,
|
|
422
|
+
pid=pid,
|
|
423
|
+
state=HostState.PROCESS_STARTED,
|
|
424
|
+
)
|
|
425
|
+
self._process_handle = record.handle
|
|
426
|
+
|
|
427
|
+
def _update_registry_state(self) -> None:
|
|
428
|
+
if self._registry is None or self._process_handle is None:
|
|
429
|
+
return
|
|
430
|
+
self._registry.update_state(self._process_handle, self.state)
|
|
431
|
+
|
|
432
|
+
def _record_exit(self, exit_code: int | None) -> None:
|
|
433
|
+
if self._registry is None or self._process_handle is None:
|
|
434
|
+
return
|
|
435
|
+
self._registry.record_exit(
|
|
436
|
+
self._process_handle,
|
|
437
|
+
exit_code,
|
|
438
|
+
termination_mode=self._termination_mode,
|
|
439
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from multiprocessing import Pipe, Process
|
|
6
|
+
from multiprocessing.connection import Connection
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
WorkerFn = Callable[[Connection], None]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ManagedProcess:
|
|
14
|
+
worker: WorkerFn
|
|
15
|
+
|
|
16
|
+
process: Optional[Process] = field(init=False, default=None)
|
|
17
|
+
connection: Optional[Connection] = field(init=False, default=None)
|
|
18
|
+
start_time: Optional[float] = field(init=False, default=None)
|
|
19
|
+
exit_code: Optional[int] = field(init=False, default=None)
|
|
20
|
+
|
|
21
|
+
def start(self) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Spawn the worker inside a multiprocessing.Process with a duplex Pipe.
|
|
24
|
+
"""
|
|
25
|
+
if self.process is not None:
|
|
26
|
+
raise RuntimeError("Process already started")
|
|
27
|
+
|
|
28
|
+
parent_conn, child_conn = Pipe()
|
|
29
|
+
|
|
30
|
+
proc = Process(
|
|
31
|
+
target=self._bootstrap_worker,
|
|
32
|
+
args=(self.worker, child_conn),
|
|
33
|
+
daemon=False,
|
|
34
|
+
)
|
|
35
|
+
proc.start()
|
|
36
|
+
|
|
37
|
+
# Parent keeps the parent-side connection only.
|
|
38
|
+
child_conn.close()
|
|
39
|
+
|
|
40
|
+
self.process = proc
|
|
41
|
+
self.connection = parent_conn
|
|
42
|
+
self.start_time = time.time()
|
|
43
|
+
|
|
44
|
+
def terminate(self) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Attempt graceful termination of the worker.
|
|
47
|
+
"""
|
|
48
|
+
if self.process is None:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
self.process.terminate()
|
|
52
|
+
if not self._wait_for_exit(timeout=1.0):
|
|
53
|
+
# Process refused to exit, escalate to a kill.
|
|
54
|
+
self.kill()
|
|
55
|
+
return
|
|
56
|
+
self._cleanup_connection()
|
|
57
|
+
|
|
58
|
+
def kill(self) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Forcefully kill the worker.
|
|
61
|
+
"""
|
|
62
|
+
if self.process is None:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
self.process.kill()
|
|
66
|
+
self._wait_for_exit(timeout=1.0)
|
|
67
|
+
self._cleanup_connection()
|
|
68
|
+
|
|
69
|
+
def poll_exit(self) -> Optional[int]:
|
|
70
|
+
"""
|
|
71
|
+
Return the worker's exit code if it has finished.
|
|
72
|
+
"""
|
|
73
|
+
if self.process is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
if self.process.exitcode is None:
|
|
77
|
+
self.process.join(timeout=0)
|
|
78
|
+
|
|
79
|
+
self.exit_code = self.process.exitcode
|
|
80
|
+
return self.exit_code
|
|
81
|
+
|
|
82
|
+
def _wait_for_exit(self, *, timeout: Optional[float]) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Wait for the subprocess to exit. Returns True if it exited.
|
|
85
|
+
"""
|
|
86
|
+
if self.process is None:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
self.process.join(timeout=timeout)
|
|
90
|
+
if self.process.exitcode is not None:
|
|
91
|
+
self.exit_code = self.process.exitcode
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def _cleanup_connection(self) -> None:
|
|
97
|
+
if self.connection is not None:
|
|
98
|
+
try:
|
|
99
|
+
self.connection.close()
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
finally:
|
|
103
|
+
self.connection = None
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _bootstrap_worker(worker: WorkerFn, conn: Connection) -> None:
|
|
107
|
+
try:
|
|
108
|
+
worker(conn)
|
|
109
|
+
finally:
|
|
110
|
+
try:
|
|
111
|
+
conn.close()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
from ferp.fscp.protocol.state import HostState
|
|
10
|
+
|
|
11
|
+
_TERMINAL_STATES = {
|
|
12
|
+
HostState.TERMINATED,
|
|
13
|
+
HostState.ERR_PROTOCOL,
|
|
14
|
+
HostState.ERR_TRANSPORT,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ProcessMetadata:
|
|
20
|
+
script_name: str
|
|
21
|
+
script_id: str | None
|
|
22
|
+
target_path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProcessRecord:
|
|
27
|
+
handle: str
|
|
28
|
+
pid: int | None
|
|
29
|
+
metadata: ProcessMetadata
|
|
30
|
+
state: HostState
|
|
31
|
+
start_time: float
|
|
32
|
+
exit_code: int | None = None
|
|
33
|
+
end_time: float | None = None
|
|
34
|
+
termination_mode: str | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_terminal(self) -> bool:
|
|
38
|
+
return self.state in _TERMINAL_STATES
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProcessRegistry:
|
|
42
|
+
"""Tracks processes spawned by the FSCP host."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._records: dict[str, ProcessRecord] = {}
|
|
46
|
+
self._lock = Lock()
|
|
47
|
+
self._counter = itertools.count(1)
|
|
48
|
+
|
|
49
|
+
def register(
|
|
50
|
+
self, metadata: ProcessMetadata, *, pid: int | None, state: HostState
|
|
51
|
+
) -> ProcessRecord:
|
|
52
|
+
handle = f"proc-{next(self._counter)}"
|
|
53
|
+
record = ProcessRecord(
|
|
54
|
+
handle=handle,
|
|
55
|
+
pid=pid,
|
|
56
|
+
metadata=metadata,
|
|
57
|
+
state=state,
|
|
58
|
+
start_time=time.time(),
|
|
59
|
+
)
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._records[handle] = record
|
|
62
|
+
return record
|
|
63
|
+
|
|
64
|
+
def update_state(self, handle: str, state: HostState) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
record = self._records.get(handle)
|
|
67
|
+
if record is None:
|
|
68
|
+
return
|
|
69
|
+
record.state = state
|
|
70
|
+
if state in _TERMINAL_STATES:
|
|
71
|
+
record.end_time = record.end_time or time.time()
|
|
72
|
+
|
|
73
|
+
def record_exit(
|
|
74
|
+
self, handle: str, exit_code: int | None, *, termination_mode: str | None
|
|
75
|
+
) -> None:
|
|
76
|
+
with self._lock:
|
|
77
|
+
record = self._records.get(handle)
|
|
78
|
+
if record is None:
|
|
79
|
+
return
|
|
80
|
+
record.exit_code = exit_code
|
|
81
|
+
if termination_mode:
|
|
82
|
+
record.termination_mode = termination_mode
|
|
83
|
+
record.end_time = record.end_time or time.time()
|
|
84
|
+
if record.state not in _TERMINAL_STATES:
|
|
85
|
+
record.state = HostState.TERMINATED
|
|
86
|
+
|
|
87
|
+
def list_all(self) -> list[ProcessRecord]:
|
|
88
|
+
with self._lock:
|
|
89
|
+
return [self._clone(record) for record in self._records.values()]
|
|
90
|
+
|
|
91
|
+
def list_active(self) -> list[ProcessRecord]:
|
|
92
|
+
with self._lock:
|
|
93
|
+
return [
|
|
94
|
+
self._clone(record)
|
|
95
|
+
for record in self._records.values()
|
|
96
|
+
if not record.is_terminal
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def prune_finished(self) -> list[ProcessRecord]:
|
|
100
|
+
with self._lock:
|
|
101
|
+
finished = [
|
|
102
|
+
handle for handle, record in self._records.items() if record.is_terminal
|
|
103
|
+
]
|
|
104
|
+
removed = [self._records.pop(handle) for handle in finished]
|
|
105
|
+
return [self._clone(record) for record in removed]
|
|
106
|
+
|
|
107
|
+
def _clone(self, record: ProcessRecord) -> ProcessRecord:
|
|
108
|
+
return ProcessRecord(
|
|
109
|
+
handle=record.handle,
|
|
110
|
+
pid=record.pid,
|
|
111
|
+
metadata=record.metadata,
|
|
112
|
+
state=record.state,
|
|
113
|
+
start_time=record.start_time,
|
|
114
|
+
exit_code=record.exit_code,
|
|
115
|
+
end_time=record.end_time,
|
|
116
|
+
termination_mode=record.termination_mode,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"ProcessMetadata",
|
|
122
|
+
"ProcessRecord",
|
|
123
|
+
"ProcessRegistry",
|
|
124
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .errors import ProtocolViolation
|
|
2
|
+
from .messages import Message, MessageDirection, MessageType
|
|
3
|
+
from .state import HostState
|
|
4
|
+
from .validator import ProtocolValidator
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"HostState",
|
|
8
|
+
"Message",
|
|
9
|
+
"MessageType",
|
|
10
|
+
"MessageDirection",
|
|
11
|
+
"ProtocolValidator",
|
|
12
|
+
"ProtocolViolation",
|
|
13
|
+
]
|