amkernel 0.1.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.
amkernel/output.py ADDED
@@ -0,0 +1,444 @@
1
+ """Execution-scoped stream spooling, bounds, previews, and publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import mmap
8
+ import tempfile
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import IO, Literal
12
+
13
+ from amkernel.artifact_store import (
14
+ ArtifactBundle,
15
+ ArtifactRecord,
16
+ ArtifactStoreError,
17
+ ArtifactWriter,
18
+ )
19
+ from amkernel.protocol import (
20
+ MAX_LIVE_MESSAGE_BYTES,
21
+ MAX_STREAM_BYTES,
22
+ SAFE_MIME_TYPES,
23
+ STREAM_TAIL_BYTES,
24
+ ArtifactBeginResponse,
25
+ ArtifactChunkResponse,
26
+ ArtifactEndResponse,
27
+ DisplayJsonResponse,
28
+ JsonValue,
29
+ Response,
30
+ )
31
+ from amkernel.structured_output import (
32
+ ImageInfo,
33
+ StructuredOutputError,
34
+ validate_safe_artifact,
35
+ )
36
+
37
+ StreamChannel = Literal["stdout", "stderr"]
38
+ OutputEventKind = Literal["stdout", "stderr", "display", "image", "artifact"]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class OutputSnapshot:
43
+ """One replaceable advisory live-text snapshot."""
44
+
45
+ revision: int
46
+ channel: OutputEventKind
47
+ text: str
48
+
49
+
50
+ class OutputObserver:
51
+ """Expose only the latest stream snapshot without producer backpressure."""
52
+
53
+ def __init__(self) -> None:
54
+ self._queue: asyncio.Queue[OutputSnapshot] = asyncio.Queue(maxsize=1)
55
+
56
+ async def next(self) -> OutputSnapshot:
57
+ return await self._queue.get()
58
+
59
+ def drain_latest(self, current: OutputSnapshot) -> OutputSnapshot:
60
+ """Replace one held observation with the newest pending observation."""
61
+ while True:
62
+ try:
63
+ current = self._queue.get_nowait()
64
+ except asyncio.QueueEmpty:
65
+ return current
66
+
67
+ def _publish(self, snapshot: OutputSnapshot) -> None:
68
+ if self._queue.full():
69
+ self._queue.get_nowait()
70
+ self._queue.put_nowait(snapshot)
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class _ByteSummary:
75
+ total_bytes: int = 0
76
+ newline_bytes: int = 0
77
+ last_byte: int | None = None
78
+
79
+ def feed(self, chunk: bytes | memoryview) -> None:
80
+ if not chunk:
81
+ return
82
+ self.total_bytes += len(chunk)
83
+ self.newline_bytes += (
84
+ chunk.count(b"\n") if isinstance(chunk, bytes) else bytes(chunk).count(b"\n")
85
+ )
86
+ self.last_byte = chunk[-1]
87
+
88
+ @property
89
+ def total_lines(self) -> int:
90
+ return self.newline_bytes + int(self.total_bytes > 0 and self.last_byte != 0x0A)
91
+
92
+
93
+ @dataclass(slots=True)
94
+ class _StreamState:
95
+ writer: ArtifactWriter
96
+ observed: _ByteSummary = field(default_factory=_ByteSummary)
97
+ discarded: _ByteSummary = field(default_factory=_ByteSummary)
98
+ tail: bytearray = field(default_factory=bytearray)
99
+ live_tail: bytearray = field(default_factory=bytearray)
100
+ accepted_bytes: int = 0
101
+
102
+ def accept(self, chunk: bytes | memoryview) -> None:
103
+ if not chunk:
104
+ return
105
+ self.writer.write(chunk)
106
+ self.accepted_bytes += len(chunk)
107
+ self._append_bounded(self.tail, chunk, STREAM_TAIL_BYTES)
108
+ self._append_bounded(self.live_tail, chunk, MAX_LIVE_MESSAGE_BYTES)
109
+
110
+ @staticmethod
111
+ def _append_bounded(target: bytearray, chunk: bytes | memoryview, maximum: int) -> None:
112
+ target.extend(chunk)
113
+ overflow = len(target) - maximum
114
+ if overflow > 0:
115
+ del target[:overflow]
116
+
117
+ @property
118
+ def truncated(self) -> bool:
119
+ return self.accepted_bytes > len(self.tail)
120
+
121
+ def preview(self) -> str:
122
+ value = bytes(self.tail)
123
+ if self.truncated:
124
+ value = _trim_utf8_prefix(value)
125
+ return value.decode("utf-8", errors="replace")
126
+
127
+ def live_text(self) -> str:
128
+ text = _trim_utf8_prefix(bytes(self.live_tail)).decode("utf-8", errors="replace")
129
+ encoded = text.encode("utf-8")
130
+ if len(encoded) <= MAX_LIVE_MESSAGE_BYTES:
131
+ return text
132
+ return _trim_utf8_prefix(encoded[-MAX_LIVE_MESSAGE_BYTES:]).decode("utf-8")
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class DisplayRecord:
137
+ sequence: int
138
+ kind: Literal["json", "image", "artifact"]
139
+ value: JsonValue | None = None
140
+ artifact: ArtifactRecord | None = None
141
+ image: ImageInfo | None = None
142
+
143
+
144
+ @dataclass(slots=True)
145
+ class _StructuredTransaction:
146
+ begin: ArtifactBeginResponse
147
+ writer: ArtifactWriter
148
+ image: IO[bytes] | None
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class OutputResult:
153
+ stdout: str
154
+ stderr: str
155
+ total_bytes: int
156
+ total_lines: int
157
+ discarded_bytes: int
158
+ discarded_lines: int
159
+ truncated: bool
160
+ limit_bytes: int
161
+ limit_exceeded: bool
162
+ stdout_artifact: ArtifactRecord | None = None
163
+ stderr_artifact: ArtifactRecord | None = None
164
+ displays: tuple[DisplayRecord, ...] = ()
165
+ explicit_artifacts: tuple[ArtifactRecord, ...] = ()
166
+
167
+ @classmethod
168
+ def empty(cls) -> OutputResult:
169
+ return cls("", "", 0, 0, 0, 0, False, MAX_STREAM_BYTES, False)
170
+
171
+ @property
172
+ def artifacts(self) -> tuple[ArtifactRecord, ...]:
173
+ """Return helper artifacts in sequence order, followed by retained streams."""
174
+ streams = tuple(
175
+ artifact
176
+ for artifact in (self.stdout_artifact, self.stderr_artifact)
177
+ if artifact is not None
178
+ )
179
+ return self.explicit_artifacts + streams
180
+
181
+
182
+ class OutputPipeline:
183
+ """Combine both observed streams into one bounded immutable execution result."""
184
+
185
+ def __init__(self, bundle: ArtifactBundle) -> None:
186
+ self._bundle = bundle
187
+ self._streams = {
188
+ "stdout": _StreamState(bundle.open_artifact("stdout", "application/octet-stream")),
189
+ "stderr": _StreamState(bundle.open_artifact("stderr", "application/octet-stream")),
190
+ }
191
+ self._accepted_bytes = 0
192
+ self._limit_exceeded = False
193
+ self._failure: ArtifactStoreError | None = None
194
+ self._settled = False
195
+ self._revision = 0
196
+ self._observer: OutputObserver | None = None
197
+ self.limit_event = asyncio.Event()
198
+ self.failure_event = asyncio.Event()
199
+ self._displays: list[DisplayRecord] = []
200
+ self._explicit_records: list[tuple[int, ArtifactRecord]] = []
201
+ self._transaction: _StructuredTransaction | None = None
202
+ self.limit_time: float | None = None
203
+ self.failure_time: float | None = None
204
+
205
+ def attach_observer(self, observer: OutputObserver | None = None) -> OutputObserver:
206
+ if self._observer is not None:
207
+ raise RuntimeError("output observer is already attached")
208
+ attached = observer if observer is not None else OutputObserver()
209
+ self._observer = attached
210
+ return attached
211
+
212
+ @property
213
+ def failure(self) -> ArtifactStoreError | None:
214
+ return self._failure
215
+
216
+ @property
217
+ def limit_exceeded(self) -> bool:
218
+ return self._limit_exceeded
219
+
220
+ def feed(self, channel: StreamChannel, chunk: bytes) -> None:
221
+ if not chunk:
222
+ return
223
+ stream = self._streams[channel]
224
+ stream.observed.feed(chunk)
225
+ if self._failure is not None:
226
+ return
227
+ if self._limit_exceeded:
228
+ stream.discarded.feed(chunk)
229
+ return
230
+
231
+ remaining = MAX_STREAM_BYTES - self._accepted_bytes
232
+ accepted = memoryview(chunk)[:remaining]
233
+ discarded = memoryview(chunk)[remaining:]
234
+ try:
235
+ stream.accept(accepted)
236
+ except ArtifactStoreError as error:
237
+ self._failure = error
238
+ self.failure_time = time.monotonic()
239
+ self.failure_event.set()
240
+ return
241
+ self._accepted_bytes += len(accepted)
242
+ if accepted:
243
+ self._publish(channel, stream.live_text())
244
+ if discarded:
245
+ self._limit_exceeded = True
246
+ self.limit_time = time.monotonic()
247
+ stream.discarded.feed(discarded)
248
+ self.limit_event.set()
249
+
250
+ def accept_response(self, response: Response) -> None:
251
+ """Persist one already protocol-validated structured output event."""
252
+ try:
253
+ if isinstance(response, DisplayJsonResponse):
254
+ self._displays.append(DisplayRecord(response.sequence, "json", response.value))
255
+ size = len(
256
+ json.dumps(
257
+ response.value,
258
+ ensure_ascii=False,
259
+ allow_nan=False,
260
+ separators=(",", ":"),
261
+ ).encode("utf-8")
262
+ )
263
+ self._publish("display", f"{size} bytes")
264
+ elif isinstance(response, ArtifactBeginResponse):
265
+ validation = (
266
+ tempfile.SpooledTemporaryFile(max_size=8_388_608)
267
+ if response.kind == "image" or response.declared_mime_type in SAFE_MIME_TYPES
268
+ else None
269
+ )
270
+ writer = self._bundle.open_artifact(
271
+ response.name,
272
+ response.declared_mime_type,
273
+ safe_mime_type=(
274
+ response.declared_mime_type
275
+ if response.kind == "image"
276
+ else "application/octet-stream"
277
+ ),
278
+ )
279
+ self._transaction = _StructuredTransaction(response, writer, validation)
280
+ elif isinstance(response, ArtifactChunkResponse):
281
+ transaction = self._require_transaction(response.transaction_id)
282
+ transaction.writer.write(response.data)
283
+ if transaction.image is not None:
284
+ transaction.image.write(response.data)
285
+ elif isinstance(response, ArtifactEndResponse):
286
+ transaction = self._require_transaction(response.transaction_id)
287
+ image_info = None
288
+ if transaction.image is not None:
289
+ transaction.image.flush()
290
+ try:
291
+ if transaction.image.tell() == 0:
292
+ validated = validate_safe_artifact(
293
+ b"", transaction.begin.declared_mime_type
294
+ )
295
+ else:
296
+ with mmap.mmap(
297
+ transaction.image.fileno(), 0, access=mmap.ACCESS_READ
298
+ ) as mapped:
299
+ validated = validate_safe_artifact(
300
+ mapped, transaction.begin.declared_mime_type
301
+ )
302
+ except StructuredOutputError:
303
+ if transaction.begin.kind == "image":
304
+ raise
305
+ else:
306
+ if transaction.begin.kind == "image":
307
+ if validated is None:
308
+ raise StructuredOutputError("image validation returned no metadata")
309
+ image_info = validated
310
+ else:
311
+ transaction.writer._set_safe_mime_type(
312
+ transaction.begin.declared_mime_type
313
+ )
314
+ finally:
315
+ transaction.image.close()
316
+ record = transaction.writer.finish()
317
+ self._displays.append(
318
+ DisplayRecord(
319
+ transaction.begin.sequence,
320
+ transaction.begin.kind,
321
+ artifact=record,
322
+ image=image_info,
323
+ )
324
+ )
325
+ self._explicit_records.append((transaction.begin.sequence, record))
326
+ self._transaction = None
327
+ self._publish(transaction.begin.kind, f"{record.size} bytes")
328
+ except (ArtifactStoreError, OSError, StructuredOutputError) as error:
329
+ if self._failure is None:
330
+ self._failure = (
331
+ error
332
+ if isinstance(error, ArtifactStoreError)
333
+ else ArtifactStoreError("structured output validation failed")
334
+ )
335
+ self.failure_time = time.monotonic()
336
+ self.failure_event.set()
337
+
338
+ def _publish(self, channel: OutputEventKind, text: str) -> None:
339
+ self._revision += 1
340
+ observer = self._observer
341
+ if observer is not None:
342
+ observer._publish(OutputSnapshot(self._revision, channel, text))
343
+
344
+ def _require_transaction(self, transaction_id: str) -> _StructuredTransaction:
345
+ transaction = self._transaction
346
+ if transaction is None or transaction.begin.transaction_id != transaction_id:
347
+ raise ArtifactStoreError("structured output transaction mismatch")
348
+ return transaction
349
+
350
+ def snapshot_result(self) -> OutputResult:
351
+ """Return current path-free accounting without unpublished references."""
352
+ return self._result({}, include_artifacts=False)
353
+
354
+ async def finish(self) -> OutputResult:
355
+ if self._settled:
356
+ raise ArtifactStoreError("output pipeline is settled")
357
+ if self._failure is not None:
358
+ await self.abort()
359
+ raise self._failure
360
+
361
+ records: dict[str, ArtifactRecord] = {}
362
+ try:
363
+ if self._transaction is not None:
364
+ if self._transaction.image is not None:
365
+ self._transaction.image.close()
366
+ self._transaction.writer.discard()
367
+ self._transaction = None
368
+ retained = bool(self._explicit_records)
369
+ for channel in ("stdout", "stderr"):
370
+ stream = self._streams[channel]
371
+ if stream.truncated:
372
+ records[channel] = stream.writer.finish()
373
+ retained = True
374
+ else:
375
+ stream.writer.discard()
376
+ if retained:
377
+ await self._bundle.publish()
378
+ else:
379
+ await self._bundle.abort()
380
+ except (ArtifactStoreError, OSError) as error:
381
+ await self._abort_after_failure()
382
+ if isinstance(error, OSError):
383
+ raise ArtifactStoreError("output cleanup failed") from error
384
+ raise
385
+ self._settled = True
386
+ return self._result(records)
387
+
388
+ async def abort(self) -> None:
389
+ if self._settled:
390
+ return
391
+ if self._transaction is not None and self._transaction.image is not None:
392
+ self._transaction.image.close()
393
+ await self._bundle.abort()
394
+ self._transaction = None
395
+ self._settled = True
396
+
397
+ async def _abort_after_failure(self) -> None:
398
+ try:
399
+ await self._bundle.abort()
400
+ except (ArtifactStoreError, OSError):
401
+ pass
402
+ self._settled = True
403
+
404
+ def _result(
405
+ self, records: dict[str, ArtifactRecord], *, include_artifacts: bool = True
406
+ ) -> OutputResult:
407
+ stdout = self._streams["stdout"]
408
+ stderr = self._streams["stderr"]
409
+ return OutputResult(
410
+ stdout=stdout.preview(),
411
+ stderr=stderr.preview(),
412
+ total_bytes=stdout.observed.total_bytes + stderr.observed.total_bytes,
413
+ total_lines=stdout.observed.total_lines + stderr.observed.total_lines,
414
+ discarded_bytes=stdout.discarded.total_bytes + stderr.discarded.total_bytes,
415
+ discarded_lines=stdout.discarded.total_lines + stderr.discarded.total_lines,
416
+ truncated=stdout.truncated or stderr.truncated,
417
+ limit_bytes=MAX_STREAM_BYTES,
418
+ limit_exceeded=self._limit_exceeded,
419
+ stdout_artifact=records.get("stdout"),
420
+ stderr_artifact=records.get("stderr"),
421
+ displays=tuple(
422
+ sorted(
423
+ (
424
+ display
425
+ for display in self._displays
426
+ if include_artifacts or display.kind == "json"
427
+ ),
428
+ key=lambda item: item.sequence,
429
+ )
430
+ ),
431
+ explicit_artifacts=(
432
+ tuple(
433
+ record for _, record in sorted(self._explicit_records, key=lambda item: item[0])
434
+ )
435
+ if include_artifacts
436
+ else ()
437
+ ),
438
+ )
439
+
440
+
441
+ def _trim_utf8_prefix(value: bytes) -> bytes:
442
+ while value and value[0] & 0xC0 == 0x80:
443
+ value = value[1:]
444
+ return value