bazaar-compute-node 0.1.3__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 (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,583 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import signal
7
+ from collections import deque
8
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ from .protocol import (
15
+ JsonlMessage,
16
+ JsonlProcessExited,
17
+ JsonlProcessNotRunning,
18
+ JsonlProtocolError,
19
+ JsonlRemoteError,
20
+ JsonlRequestId,
21
+ JsonlRequestTimeout,
22
+ JsonlTransportError,
23
+ is_request_id,
24
+ validate_message,
25
+ )
26
+
27
+ StderrHandler = Callable[[str], Awaitable[None] | None]
28
+
29
+
30
+ class JsonlProcessState(StrEnum):
31
+ STOPPED = "stopped"
32
+ STARTING = "starting"
33
+ RUNNING = "running"
34
+ STOPPING = "stopping"
35
+ EXITED = "exited"
36
+ FAILED = "failed"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class JsonlProcessSpec:
41
+ executable: str
42
+ arguments: tuple[str, ...] = ()
43
+ cwd: Path | None = None
44
+ environment: Mapping[str, str] | None = None
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.executable:
48
+ raise ValueError("executable must be a non-empty string")
49
+ if any(not isinstance(argument, str) for argument in self.arguments):
50
+ raise TypeError("arguments must contain only strings")
51
+ if self.cwd is not None and not isinstance(self.cwd, Path):
52
+ raise TypeError("cwd must be a Path or None")
53
+ if self.environment is not None and any(
54
+ not isinstance(key, str) or not isinstance(value, str)
55
+ for key, value in self.environment.items()
56
+ ):
57
+ raise TypeError("environment must contain only string keys and values")
58
+
59
+ @property
60
+ def command(self) -> tuple[str, ...]:
61
+ return (self.executable, *self.arguments)
62
+
63
+
64
+ _QUEUE_CLOSED = object()
65
+ _JSONL_READ_CHUNK_BYTES = 64 * 1024
66
+
67
+
68
+ class JsonlProcessSupervisor:
69
+ """Own one subprocess speaking newline-delimited JSON over stdio."""
70
+
71
+ def __init__(
72
+ self,
73
+ spec: JsonlProcessSpec,
74
+ *,
75
+ stderr_tail_limit: int = 64,
76
+ stderr_handler: StderrHandler | None = None,
77
+ ) -> None:
78
+ if stderr_tail_limit <= 0:
79
+ raise ValueError("stderr_tail_limit must be positive")
80
+ self.spec = spec
81
+ self._stderr_tail: deque[str] = deque(maxlen=stderr_tail_limit)
82
+ self._stderr_handler = stderr_handler
83
+ self._process: asyncio.subprocess.Process | None = None
84
+ self._state = JsonlProcessState.STOPPED
85
+ self._returncode: int | None = None
86
+ self._fatal_error: JsonlTransportError | None = None
87
+ self._stdout_task: asyncio.Task[None] | None = None
88
+ self._stderr_task: asyncio.Task[None] | None = None
89
+ self._watch_task: asyncio.Task[None] | None = None
90
+ self._write_lock = asyncio.Lock()
91
+ self._lifecycle_lock = asyncio.Lock()
92
+ self._exit_event = asyncio.Event()
93
+ self._incoming: asyncio.Queue[JsonlMessage | object] = asyncio.Queue()
94
+ self._pending: dict[JsonlRequestId, asyncio.Future[JsonlMessage]] = {}
95
+ self._next_request_id = 0
96
+ self._closed_message_sent = False
97
+
98
+ @property
99
+ def state(self) -> JsonlProcessState:
100
+ return self._state
101
+
102
+ @property
103
+ def pid(self) -> int | None:
104
+ process = self._process
105
+ return process.pid if process is not None else None
106
+
107
+ @property
108
+ def returncode(self) -> int | None:
109
+ return self._returncode
110
+
111
+ @property
112
+ def is_running(self) -> bool:
113
+ process = self._process
114
+ return process is not None and process.returncode is None
115
+
116
+ @property
117
+ def fatal_error(self) -> JsonlTransportError | None:
118
+ return self._fatal_error
119
+
120
+ @property
121
+ def stderr_tail(self) -> tuple[str, ...]:
122
+ return tuple(self._stderr_tail)
123
+
124
+ async def start(self, *, timeout: float) -> None:
125
+ _validate_timeout(timeout)
126
+ async with self._lifecycle_lock:
127
+ if self.is_running:
128
+ return
129
+ await self._join_tasks()
130
+ self._reset_runtime_state()
131
+ self._state = JsonlProcessState.STARTING
132
+ try:
133
+ async with asyncio.timeout(timeout):
134
+ self._process = await asyncio.create_subprocess_exec(
135
+ *self.spec.command,
136
+ stdin=asyncio.subprocess.PIPE,
137
+ stdout=asyncio.subprocess.PIPE,
138
+ stderr=asyncio.subprocess.PIPE,
139
+ cwd=(str(self.spec.cwd) if self.spec.cwd is not None else None),
140
+ env=(
141
+ dict(self.spec.environment)
142
+ if self.spec.environment is not None
143
+ else None
144
+ ),
145
+ )
146
+ except BaseException:
147
+ self._process = None
148
+ self._state = JsonlProcessState.FAILED
149
+ raise
150
+ self._state = JsonlProcessState.RUNNING
151
+ process = self._process
152
+ self._stdout_task = asyncio.create_task(
153
+ self._read_stdout(process),
154
+ name="codex-app-server-stdout",
155
+ )
156
+ self._stderr_task = asyncio.create_task(
157
+ self._read_stderr(process),
158
+ name="codex-app-server-stderr",
159
+ )
160
+ self._watch_task = asyncio.create_task(
161
+ self._watch_process(process),
162
+ name="codex-app-server-process",
163
+ )
164
+
165
+ async def stop(self, *, timeout: float) -> None:
166
+ _validate_timeout(timeout)
167
+ async with self._lifecycle_lock:
168
+ process = self._process
169
+ if process is None:
170
+ await self._join_tasks()
171
+ self._state = JsonlProcessState.STOPPED
172
+ self._send_closed_message()
173
+ return
174
+ self._state = JsonlProcessState.STOPPING
175
+ deadline = asyncio.get_running_loop().time() + timeout
176
+ if process.stdin is not None:
177
+ process.stdin.close()
178
+ try:
179
+ await self._wait_for_process(process, deadline)
180
+ except TimeoutError:
181
+ _terminate_process(process)
182
+ try:
183
+ await self._wait_for_process(process, deadline)
184
+ except TimeoutError:
185
+ _kill_process(process)
186
+ await process.wait()
187
+ await self._join_tasks()
188
+ self._state = JsonlProcessState.STOPPED
189
+ self._send_closed_message()
190
+
191
+ async def wait(self, *, timeout: float | None = None) -> int | None:
192
+ if self._process is None:
193
+ return self._returncode
194
+ if timeout is None:
195
+ await self._exit_event.wait()
196
+ else:
197
+ _validate_timeout(timeout)
198
+ async with asyncio.timeout(timeout):
199
+ await self._exit_event.wait()
200
+ return self._returncode
201
+
202
+ async def request(
203
+ self,
204
+ method: str,
205
+ params: Mapping[str, object] | None = None,
206
+ *,
207
+ timeout: float,
208
+ ) -> JsonlMessage:
209
+ _validate_method(method)
210
+ _validate_timeout(timeout)
211
+ self._ensure_running()
212
+ request_id = self._next_id()
213
+ loop = asyncio.get_running_loop()
214
+ future = loop.create_future()
215
+ self._pending[request_id] = future
216
+ payload: JsonlMessage = {"id": request_id, "method": method}
217
+ if params is not None:
218
+ payload["params"] = dict(params)
219
+ try:
220
+ async with asyncio.timeout(timeout):
221
+ await self._write_message(payload)
222
+ return await asyncio.shield(future)
223
+ except TimeoutError:
224
+ self._remove_pending(request_id, future)
225
+ raise JsonlRequestTimeout(request_id=request_id, method=method) from None
226
+ except asyncio.CancelledError:
227
+ self._remove_pending(request_id, future)
228
+ raise
229
+ except BaseException:
230
+ self._remove_pending(request_id, future)
231
+ raise
232
+
233
+ async def notify(
234
+ self,
235
+ method: str,
236
+ params: Mapping[str, object] | None = None,
237
+ *,
238
+ timeout: float,
239
+ ) -> None:
240
+ _validate_method(method)
241
+ _validate_timeout(timeout)
242
+ self._ensure_running()
243
+ payload: JsonlMessage = {"method": method}
244
+ if params is not None:
245
+ payload["params"] = dict(params)
246
+ async with asyncio.timeout(timeout):
247
+ await self._write_message(payload)
248
+
249
+ async def respond(
250
+ self,
251
+ request_id: JsonlRequestId,
252
+ *,
253
+ result: Mapping[str, object] | None = None,
254
+ error: Mapping[str, object] | None = None,
255
+ timeout: float,
256
+ ) -> None:
257
+ """Write one response to a provider-initiated JSON-RPC request."""
258
+
259
+ if not is_request_id(request_id):
260
+ raise TypeError("request_id must be an integer or string")
261
+ if result is not None and error is not None:
262
+ raise ValueError("a JSONL response cannot contain both result and error")
263
+ if result is None and error is None:
264
+ result = {}
265
+ if result is not None and not isinstance(result, Mapping):
266
+ raise TypeError("result must be a mapping or None")
267
+ if error is not None and not isinstance(error, Mapping):
268
+ raise TypeError("error must be a mapping or None")
269
+ payload: JsonlMessage = {"id": request_id}
270
+ if result is not None:
271
+ payload["result"] = dict(result)
272
+ else:
273
+ payload["error"] = dict(error or {})
274
+ _validate_timeout(timeout)
275
+ async with asyncio.timeout(timeout):
276
+ await self._write_message(payload)
277
+
278
+ async def receive(self, *, timeout: float | None = None) -> JsonlMessage:
279
+ if timeout is None:
280
+ item = await self._incoming.get()
281
+ else:
282
+ _validate_timeout(timeout)
283
+ async with asyncio.timeout(timeout):
284
+ item = await self._incoming.get()
285
+ if item is _QUEUE_CLOSED:
286
+ error = self._fatal_error
287
+ if error is not None:
288
+ raise error
289
+ raise JsonlProcessExited(
290
+ returncode=self._returncode,
291
+ stderr_tail=self.stderr_tail,
292
+ )
293
+ return cast(JsonlMessage, item)
294
+
295
+ async def incoming(self) -> AsyncIterator[JsonlMessage]:
296
+ while True:
297
+ yield await self.receive()
298
+
299
+ async def _write_message(self, payload: Mapping[str, object]) -> None:
300
+ message = validate_message(payload)
301
+ try:
302
+ encoded = (
303
+ json.dumps(
304
+ message,
305
+ ensure_ascii=False,
306
+ separators=(",", ":"),
307
+ ).encode("utf-8")
308
+ + b"\n"
309
+ )
310
+ except (TypeError, ValueError) as error:
311
+ raise JsonlProtocolError(
312
+ "outgoing JSONL message is not serializable"
313
+ ) from error
314
+ async with self._write_lock:
315
+ self._ensure_running()
316
+ process = self._process
317
+ if process is None or process.stdin is None:
318
+ raise JsonlProcessNotRunning()
319
+ try:
320
+ process.stdin.write(encoded)
321
+ await process.stdin.drain()
322
+ except (BrokenPipeError, ConnectionError, OSError) as error:
323
+ raise JsonlProcessExited(
324
+ returncode=process.returncode,
325
+ stderr_tail=self.stderr_tail,
326
+ ) from error
327
+
328
+ async def _read_stdout(self, process: asyncio.subprocess.Process) -> None:
329
+ stdout = process.stdout
330
+ if stdout is None:
331
+ await self._protocol_failure("stdout pipe is unavailable")
332
+ return
333
+ line_number = 0
334
+ buffer = bytearray()
335
+ try:
336
+ while True:
337
+ chunk = await stdout.read(_JSONL_READ_CHUNK_BYTES)
338
+ if chunk:
339
+ buffer.extend(chunk)
340
+ elif buffer:
341
+ buffer.append(ord("\n"))
342
+ else:
343
+ break
344
+ while True:
345
+ boundary = buffer.find(b"\n")
346
+ if boundary < 0:
347
+ break
348
+ line = bytes(buffer[:boundary])
349
+ del buffer[: boundary + 1]
350
+ line_number += 1
351
+ try:
352
+ decoded = line.decode("utf-8")
353
+ payload = json.loads(decoded)
354
+ except UnicodeDecodeError, json.JSONDecodeError:
355
+ await self._protocol_failure(
356
+ "stdout contains invalid JSONL",
357
+ line_number=line_number,
358
+ )
359
+ return
360
+ if not isinstance(payload, dict):
361
+ await self._protocol_failure(
362
+ "stdout JSONL item must be an object",
363
+ line_number=line_number,
364
+ )
365
+ return
366
+ self._route_message(cast(JsonlMessage, payload))
367
+ if not chunk:
368
+ break
369
+ except asyncio.CancelledError:
370
+ raise
371
+ except (ConnectionError, OSError) as error:
372
+ await self._protocol_failure(f"stdout read failed: {type(error).__name__}")
373
+
374
+ async def _read_stderr(self, process: asyncio.subprocess.Process) -> None:
375
+ stderr = process.stderr
376
+ if stderr is None:
377
+ return
378
+ try:
379
+ while line := await stderr.readline():
380
+ text = line.decode("utf-8", errors="replace").rstrip("\r\n")
381
+ self._stderr_tail.append(text)
382
+ if self._stderr_handler is not None:
383
+ result = self._stderr_handler(text)
384
+ if result is not None:
385
+ await result
386
+ except asyncio.CancelledError:
387
+ raise
388
+ except ConnectionError, OSError:
389
+ return
390
+
391
+ async def _watch_process(self, process: asyncio.subprocess.Process) -> None:
392
+ returncode = await process.wait()
393
+ self._returncode = returncode
394
+ stdout_task = self._stdout_task
395
+ if stdout_task is not None and stdout_task is not asyncio.current_task():
396
+ await asyncio.gather(stdout_task, return_exceptions=True)
397
+ stderr_task = self._stderr_task
398
+ if stderr_task is not None and stderr_task is not asyncio.current_task():
399
+ await asyncio.gather(stderr_task, return_exceptions=True)
400
+ if self._fatal_error is None and self._pending:
401
+ await self._fail_pending(
402
+ JsonlProcessExited(
403
+ returncode=returncode,
404
+ stderr_tail=self.stderr_tail,
405
+ )
406
+ )
407
+ if self._fatal_error is None and returncode != 0:
408
+ self._fatal_error = JsonlProcessExited(
409
+ returncode=returncode,
410
+ stderr_tail=self.stderr_tail,
411
+ )
412
+ self._state = (
413
+ JsonlProcessState.FAILED
414
+ if self._fatal_error is not None or returncode != 0
415
+ else JsonlProcessState.EXITED
416
+ )
417
+ self._exit_event.set()
418
+ self._send_closed_message()
419
+
420
+ def _route_message(self, payload: JsonlMessage) -> None:
421
+ raw_id = payload.get("id")
422
+ if raw_id is not None and not is_request_id(raw_id):
423
+ self._schedule_protocol_failure("JSONL message id is invalid")
424
+ return
425
+ if raw_id is not None and "method" not in payload:
426
+ future = self._pending.pop(cast(JsonlRequestId, raw_id), None)
427
+ if future is None:
428
+ self._incoming.put_nowait(payload)
429
+ return
430
+ if future.done():
431
+ return
432
+ if "error" in payload:
433
+ error = payload["error"]
434
+ code: int | str | None = None
435
+ message = "remote JSONL request failed"
436
+ if isinstance(error, Mapping):
437
+ raw_code = error.get("code")
438
+ if isinstance(raw_code, (int, str)) and not isinstance(
439
+ raw_code, bool
440
+ ):
441
+ code = raw_code
442
+ raw_message = error.get("message")
443
+ if isinstance(raw_message, str) and raw_message:
444
+ message = raw_message
445
+ future.set_exception(
446
+ JsonlRemoteError(
447
+ request_id=cast(JsonlRequestId, raw_id),
448
+ code=code,
449
+ message=message,
450
+ )
451
+ )
452
+ return
453
+ future.set_result(payload)
454
+ return
455
+ self._incoming.put_nowait(payload)
456
+
457
+ async def _protocol_failure(
458
+ self,
459
+ message: str,
460
+ *,
461
+ line_number: int | None = None,
462
+ ) -> None:
463
+ error = JsonlProtocolError(message, line_number=line_number)
464
+ if self._fatal_error is None:
465
+ self._fatal_error = error
466
+ await self._fail_pending(error)
467
+ process = self._process
468
+ if process is not None and process.returncode is None:
469
+ _terminate_process(process)
470
+
471
+ def _schedule_protocol_failure(self, message: str) -> None:
472
+ asyncio.create_task(
473
+ self._protocol_failure(message),
474
+ name="codex-app-server-protocol-failure",
475
+ )
476
+
477
+ async def _fail_pending(self, error: JsonlTransportError) -> None:
478
+ pending = tuple(self._pending.values())
479
+ self._pending.clear()
480
+ for future in pending:
481
+ if not future.done():
482
+ future.set_exception(error)
483
+
484
+ async def _wait_for_process(
485
+ self,
486
+ process: asyncio.subprocess.Process,
487
+ deadline: float,
488
+ ) -> None:
489
+ remaining = deadline - asyncio.get_running_loop().time()
490
+ if remaining <= 0:
491
+ raise TimeoutError
492
+ async with asyncio.timeout(remaining):
493
+ await asyncio.shield(process.wait())
494
+
495
+ async def _join_tasks(self) -> None:
496
+ tasks = tuple(
497
+ task
498
+ for task in (self._stdout_task, self._stderr_task, self._watch_task)
499
+ if task is not None and task is not asyncio.current_task()
500
+ )
501
+ if tasks:
502
+ await asyncio.gather(*tasks, return_exceptions=True)
503
+ self._stdout_task = None
504
+ self._stderr_task = None
505
+ self._watch_task = None
506
+
507
+ def _reset_runtime_state(self) -> None:
508
+ while True:
509
+ try:
510
+ self._incoming.get_nowait()
511
+ except asyncio.QueueEmpty:
512
+ break
513
+ self._stderr_tail.clear()
514
+ self._returncode = None
515
+ self._fatal_error = None
516
+ self._next_request_id = 0
517
+ self._closed_message_sent = False
518
+ self._exit_event.clear()
519
+ self._pending.clear()
520
+
521
+ def _send_closed_message(self) -> None:
522
+ if self._closed_message_sent:
523
+ return
524
+ self._closed_message_sent = True
525
+ self._incoming.put_nowait(_QUEUE_CLOSED)
526
+
527
+ def _remove_pending(
528
+ self,
529
+ request_id: JsonlRequestId,
530
+ future: asyncio.Future[JsonlMessage],
531
+ ) -> None:
532
+ if self._pending.get(request_id) is future:
533
+ self._pending.pop(request_id, None)
534
+ if not future.done():
535
+ future.cancel()
536
+
537
+ def _next_id(self) -> int:
538
+ self._next_request_id += 1
539
+ return self._next_request_id
540
+
541
+ def _ensure_running(self) -> None:
542
+ if not self.is_running:
543
+ raise JsonlProcessNotRunning()
544
+
545
+
546
+ def _validate_timeout(timeout: float) -> None:
547
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
548
+ raise TypeError("timeout must be a positive number")
549
+ if timeout <= 0:
550
+ raise ValueError("timeout must be positive")
551
+
552
+
553
+ def _validate_method(method: str) -> None:
554
+ if not isinstance(method, str) or not method:
555
+ raise ValueError("method must be a non-empty string")
556
+
557
+
558
+ def _terminate_process(process: asyncio.subprocess.Process) -> None:
559
+ if process.returncode is not None:
560
+ return
561
+ try:
562
+ if os.name == "nt":
563
+ process.terminate()
564
+ else:
565
+ process.send_signal(signal.SIGTERM)
566
+ except ProcessLookupError, OSError:
567
+ return
568
+
569
+
570
+ def _kill_process(process: asyncio.subprocess.Process) -> None:
571
+ if process.returncode is not None:
572
+ return
573
+ try:
574
+ process.kill()
575
+ except ProcessLookupError, OSError:
576
+ return
577
+
578
+
579
+ __all__ = [
580
+ "JsonlProcessSpec",
581
+ "JsonlProcessState",
582
+ "JsonlProcessSupervisor",
583
+ ]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ type JsonlMessage = dict[str, object]
6
+ type JsonlRequestId = int | str
7
+
8
+
9
+ class JsonlTransportError(RuntimeError):
10
+ """Base error for the adapter-local JSONL process boundary."""
11
+
12
+ def __init__(self, message: str, *, kind: str) -> None:
13
+ super().__init__(message)
14
+ self.kind = kind
15
+
16
+
17
+ class JsonlProcessNotRunning(JsonlTransportError):
18
+ def __init__(self) -> None:
19
+ super().__init__(
20
+ "JSONL process is not running",
21
+ kind="process_not_running",
22
+ )
23
+
24
+
25
+ class JsonlProcessExited(JsonlTransportError):
26
+ def __init__(
27
+ self,
28
+ *,
29
+ returncode: int | None,
30
+ stderr_tail: tuple[str, ...] = (),
31
+ ) -> None:
32
+ self.returncode = returncode
33
+ self.stderr_tail = stderr_tail
34
+ returncode_text = "unknown" if returncode is None else str(returncode)
35
+ super().__init__(
36
+ f"JSONL process exited with return code {returncode_text}",
37
+ kind="process_exited",
38
+ )
39
+
40
+
41
+ class JsonlProtocolError(JsonlTransportError):
42
+ def __init__(self, message: str, *, line_number: int | None = None) -> None:
43
+ self.line_number = line_number
44
+ super().__init__(message, kind="protocol_error")
45
+
46
+
47
+ class JsonlRequestTimeout(TimeoutError, JsonlTransportError):
48
+ def __init__(self, *, request_id: JsonlRequestId, method: str) -> None:
49
+ self.request_id = request_id
50
+ self.method = method
51
+ TimeoutError.__init__(
52
+ self,
53
+ f"JSONL request {method!r} timed out for id {request_id!r}",
54
+ )
55
+ self.kind = "request_timeout"
56
+
57
+
58
+ class JsonlRemoteError(JsonlTransportError):
59
+ def __init__(
60
+ self,
61
+ *,
62
+ request_id: JsonlRequestId,
63
+ code: int | str | None,
64
+ message: str,
65
+ ) -> None:
66
+ self.request_id = request_id
67
+ self.code = code
68
+ self.remote_message = message
69
+ super().__init__(
70
+ f"JSONL request {request_id!r} failed: {message}",
71
+ kind="remote_error",
72
+ )
73
+
74
+
75
+ class CodexAppServerProtocolError(ValueError):
76
+ """The Codex App Server response does not match its provider contract."""
77
+
78
+
79
+ def validate_message(payload: Mapping[str, object]) -> JsonlMessage:
80
+ """Copy a mapping into the transport's provider-local message shape."""
81
+
82
+ if not isinstance(payload, Mapping):
83
+ raise TypeError("JSONL message must be a mapping")
84
+ return dict(payload)
85
+
86
+
87
+ def is_request_id(value: object) -> bool:
88
+ return isinstance(value, (int, str)) and not isinstance(value, bool)
89
+
90
+
91
+ __all__ = [
92
+ "CodexAppServerProtocolError",
93
+ "JsonlMessage",
94
+ "JsonlProcessExited",
95
+ "JsonlProcessNotRunning",
96
+ "JsonlProtocolError",
97
+ "JsonlRemoteError",
98
+ "JsonlRequestId",
99
+ "JsonlRequestTimeout",
100
+ "JsonlTransportError",
101
+ "is_request_id",
102
+ "validate_message",
103
+ ]