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.
Files changed (87) hide show
  1. ferp/__init__.py +3 -0
  2. ferp/__main__.py +4 -0
  3. ferp/__version__.py +1 -0
  4. ferp/app.py +9 -0
  5. ferp/cli.py +160 -0
  6. ferp/core/__init__.py +0 -0
  7. ferp/core/app.py +1312 -0
  8. ferp/core/bundle_installer.py +245 -0
  9. ferp/core/command_provider.py +77 -0
  10. ferp/core/dependency_manager.py +59 -0
  11. ferp/core/fs_controller.py +70 -0
  12. ferp/core/fs_watcher.py +144 -0
  13. ferp/core/messages.py +49 -0
  14. ferp/core/path_actions.py +124 -0
  15. ferp/core/paths.py +3 -0
  16. ferp/core/protocols.py +8 -0
  17. ferp/core/script_controller.py +515 -0
  18. ferp/core/script_protocol.py +35 -0
  19. ferp/core/script_runner.py +421 -0
  20. ferp/core/settings.py +16 -0
  21. ferp/core/settings_store.py +69 -0
  22. ferp/core/state.py +156 -0
  23. ferp/core/task_store.py +164 -0
  24. ferp/core/transcript_logger.py +95 -0
  25. ferp/domain/__init__.py +0 -0
  26. ferp/domain/scripts.py +29 -0
  27. ferp/fscp/host/__init__.py +11 -0
  28. ferp/fscp/host/host.py +439 -0
  29. ferp/fscp/host/managed_process.py +113 -0
  30. ferp/fscp/host/process_registry.py +124 -0
  31. ferp/fscp/protocol/__init__.py +13 -0
  32. ferp/fscp/protocol/errors.py +2 -0
  33. ferp/fscp/protocol/messages.py +55 -0
  34. ferp/fscp/protocol/schemas/__init__.py +0 -0
  35. ferp/fscp/protocol/schemas/fscp/1.0/cancel.json +16 -0
  36. ferp/fscp/protocol/schemas/fscp/1.0/definitions.json +29 -0
  37. ferp/fscp/protocol/schemas/fscp/1.0/discriminator.json +14 -0
  38. ferp/fscp/protocol/schemas/fscp/1.0/envelope.json +13 -0
  39. ferp/fscp/protocol/schemas/fscp/1.0/exit.json +20 -0
  40. ferp/fscp/protocol/schemas/fscp/1.0/init.json +36 -0
  41. ferp/fscp/protocol/schemas/fscp/1.0/input_response.json +21 -0
  42. ferp/fscp/protocol/schemas/fscp/1.0/log.json +21 -0
  43. ferp/fscp/protocol/schemas/fscp/1.0/message.json +23 -0
  44. ferp/fscp/protocol/schemas/fscp/1.0/progress.json +23 -0
  45. ferp/fscp/protocol/schemas/fscp/1.0/request_input.json +47 -0
  46. ferp/fscp/protocol/schemas/fscp/1.0/result.json +16 -0
  47. ferp/fscp/protocol/schemas/fscp/__init__.py +0 -0
  48. ferp/fscp/protocol/state.py +16 -0
  49. ferp/fscp/protocol/validator.py +123 -0
  50. ferp/fscp/scripts/__init__.py +0 -0
  51. ferp/fscp/scripts/runtime/__init__.py +4 -0
  52. ferp/fscp/scripts/runtime/__main__.py +40 -0
  53. ferp/fscp/scripts/runtime/errors.py +14 -0
  54. ferp/fscp/scripts/runtime/io.py +64 -0
  55. ferp/fscp/scripts/runtime/script.py +149 -0
  56. ferp/fscp/scripts/runtime/state.py +17 -0
  57. ferp/fscp/scripts/runtime/worker.py +13 -0
  58. ferp/fscp/scripts/sdk.py +548 -0
  59. ferp/fscp/transcript/__init__.py +3 -0
  60. ferp/fscp/transcript/events.py +14 -0
  61. ferp/resources/__init__.py +0 -0
  62. ferp/services/__init__.py +3 -0
  63. ferp/services/file_listing.py +120 -0
  64. ferp/services/monday_sync.py +155 -0
  65. ferp/services/releases.py +214 -0
  66. ferp/services/scripts.py +90 -0
  67. ferp/services/update_check.py +130 -0
  68. ferp/styles/index.tcss +638 -0
  69. ferp/themes/themes.py +238 -0
  70. ferp/widgets/__init__.py +17 -0
  71. ferp/widgets/dialogs.py +167 -0
  72. ferp/widgets/file_tree.py +991 -0
  73. ferp/widgets/forms.py +146 -0
  74. ferp/widgets/output_panel.py +244 -0
  75. ferp/widgets/panels.py +13 -0
  76. ferp/widgets/process_list.py +158 -0
  77. ferp/widgets/readme_modal.py +59 -0
  78. ferp/widgets/scripts.py +192 -0
  79. ferp/widgets/task_capture.py +74 -0
  80. ferp/widgets/task_list.py +493 -0
  81. ferp/widgets/top_bar.py +110 -0
  82. ferp-0.7.1.dist-info/METADATA +128 -0
  83. ferp-0.7.1.dist-info/RECORD +87 -0
  84. ferp-0.7.1.dist-info/WHEEL +5 -0
  85. ferp-0.7.1.dist-info/entry_points.txt +2 -0
  86. ferp-0.7.1.dist-info/licenses/LICENSE +21 -0
  87. 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
+ ]
@@ -0,0 +1,2 @@
1
+ class ProtocolViolation(RuntimeError):
2
+ """Raised when protocol rules are violated."""