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,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("bazaar-compute-node")
@@ -0,0 +1 @@
1
+ """Application composition and local command transport."""
@@ -0,0 +1,398 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hmac
5
+ import os
6
+ import re
7
+ import secrets
8
+ import signal
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+
12
+ from ..core.channel import ChannelContext, IChannel
13
+ from ..core.lifecycle import TimeoutBudget
14
+ from ..core.models import RuntimeSession
15
+ from ..core.observability import IAudit
16
+ from ..core.orchestration import SessionOrchestrator
17
+ from ..core.paths import resolve_data_dir, resolve_workspace_dir
18
+ from ..core.runtime import IRuntime, RuntimeCommandContext, RuntimeSandboxMode
19
+ from ..core.storage import IStorage, NodeIdentity
20
+ from .attachments import AttachmentMaterializer
21
+ from .command import (
22
+ CommandDispatcher,
23
+ CommandDispatchError,
24
+ )
25
+ from .registry import AdapterFactories
26
+ from .transport import LocalCommandServer
27
+ from .wrapper import install_bcc_wrapper, remove_bcc_wrapper
28
+
29
+ CommandRecord = tuple[str, tuple[str, ...]]
30
+
31
+ _ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
32
+ _PLATFORM_ENVIRONMENT = {
33
+ "HOME",
34
+ "LANG",
35
+ "LC_ALL",
36
+ "PATH",
37
+ "TMPDIR",
38
+ "TEMP",
39
+ "TMP",
40
+ "SystemRoot",
41
+ "ComSpec",
42
+ "PATHEXT",
43
+ "USERPROFILE",
44
+ }
45
+ _FORBIDDEN_ENVIRONMENT = {
46
+ "BCN_WECOM_BOT_SECRET",
47
+ "DATABASE_URL",
48
+ "AWS_ACCESS_KEY_ID",
49
+ "AWS_SECRET_ACCESS_KEY",
50
+ "OPENAI_API_KEY",
51
+ }
52
+
53
+
54
+ class NodeApplication:
55
+ """Generic application lifecycle for one dynamically composed node."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ factories: AdapterFactories,
61
+ endpoint_path: Path | None = None,
62
+ node_id: str = "bcn-node",
63
+ workspace_id: str | None = None,
64
+ runtime_options: Mapping[str, str] | None = None,
65
+ runtime_sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE,
66
+ runtime_network_access: bool = True,
67
+ channel_options: Mapping[str, object] | None = None,
68
+ runtime_environment_include: Sequence[str] = (),
69
+ timeout_budget: TimeoutBudget | None = None,
70
+ ) -> None:
71
+ self.data_dir = resolve_data_dir()
72
+ self.runtime_options = dict(runtime_options or {})
73
+ self.timeout_budget = timeout_budget or TimeoutBudget(
74
+ startup_seconds=60,
75
+ provider_call_seconds=600,
76
+ command_seconds=10,
77
+ shutdown_seconds=5,
78
+ )
79
+ self.storage: IStorage = factories.storage()
80
+ self.audit: IAudit = factories.audit()
81
+ self.command_log: list[CommandRecord] = []
82
+ self._wrapper_path: Path | None = None
83
+ self._identity: NodeIdentity | None = None
84
+ self._session_capabilities: dict[str, str] = {}
85
+ self._runtime_session_ids: dict[str, str] = {}
86
+ self._started = False
87
+ self._stopped = asyncio.Event()
88
+ self._runtime_environment_include = tuple(runtime_environment_include)
89
+ self._attachment_materializer = AttachmentMaterializer(
90
+ self._workspace_path, self._referenced_attachment_paths
91
+ )
92
+ self.channel: IChannel = factories.channel(
93
+ ChannelContext(
94
+ attachments=self._attachment_materializer,
95
+ options=dict(channel_options or {}),
96
+ workspace=self._workspace_path,
97
+ )
98
+ )
99
+ self._runtime_context = RuntimeCommandContext(
100
+ run_command=self._run_runtime_command,
101
+ environment_for_session=self._runtime_environment,
102
+ node_id=node_id,
103
+ runtime_options=self.runtime_options,
104
+ sandbox_mode=runtime_sandbox_mode,
105
+ network_access=runtime_network_access,
106
+ startup_timeout_seconds=self.timeout_budget.startup_seconds,
107
+ )
108
+ self.runtime: IRuntime = factories.runtime(self._runtime_context)
109
+ self.orchestrator = SessionOrchestrator(
110
+ node_id=node_id,
111
+ workspace_id=workspace_id,
112
+ channel=self.channel,
113
+ runtime=self.runtime,
114
+ storage=self.storage,
115
+ audit=self.audit,
116
+ timeout_budget=self.timeout_budget,
117
+ on_node_initialized=self._ensure_workspace,
118
+ )
119
+ self.command_service = self.orchestrator.command_service
120
+ control_handler = None
121
+ if factories.control is not None:
122
+ control_handler = factories.control(self._adapter_context())
123
+ self.command_dispatcher = CommandDispatcher(
124
+ self.command_service,
125
+ timeout_budget=self.timeout_budget,
126
+ control_handler=self._handle_control,
127
+ session_binding_validator=self._validate_session_binding,
128
+ )
129
+ self.command_server = LocalCommandServer(
130
+ self.command_dispatcher,
131
+ endpoint_path=endpoint_path,
132
+ )
133
+ self._provider_control_handler = control_handler
134
+
135
+ @property
136
+ def endpoint(self) -> str:
137
+ return self.command_server.endpoint
138
+
139
+ async def start(self) -> None:
140
+ if self._started:
141
+ return
142
+ self.data_dir.mkdir(parents=True, exist_ok=True)
143
+ if os.name != "nt":
144
+ self.data_dir.chmod(0o700)
145
+ self._wrapper_path = install_bcc_wrapper(self.data_dir / "bin")
146
+ try:
147
+ await self.command_server.start()
148
+ await self.orchestrator.start(
149
+ timeout=self.timeout_budget.startup_seconds,
150
+ )
151
+ except BaseException:
152
+ await self.stop()
153
+ raise
154
+ self._started = True
155
+ self._stopped.clear()
156
+ self.command_dispatcher.start_accepting()
157
+
158
+ async def stop(self) -> None:
159
+ if not self._started:
160
+ try:
161
+ await self.command_dispatcher.drain(
162
+ timeout=self.timeout_budget.shutdown_seconds,
163
+ )
164
+ finally:
165
+ try:
166
+ await self.command_server.stop()
167
+ finally:
168
+ self._cleanup_bcc_wrapper()
169
+ return
170
+ self._started = False
171
+ self.command_dispatcher.stop_accepting()
172
+ try:
173
+ await self.command_dispatcher.drain(
174
+ timeout=self.timeout_budget.shutdown_seconds,
175
+ )
176
+ finally:
177
+ try:
178
+ await self.orchestrator.stop(
179
+ timeout=self.timeout_budget.shutdown_seconds,
180
+ )
181
+ finally:
182
+ try:
183
+ await self.command_server.stop()
184
+ finally:
185
+ try:
186
+ self._cleanup_bcc_wrapper()
187
+ finally:
188
+ self._stopped.set()
189
+
190
+ def _cleanup_bcc_wrapper(self) -> None:
191
+ wrapper_path = self._wrapper_path
192
+ if wrapper_path is None:
193
+ return
194
+ remove_bcc_wrapper(wrapper_path)
195
+ self._wrapper_path = None
196
+
197
+ async def wait(self) -> None:
198
+ loop = asyncio.get_running_loop()
199
+ for signum in (signal.SIGINT, signal.SIGTERM):
200
+ try:
201
+ loop.add_signal_handler(signum, self._stopped.set)
202
+ except NotImplementedError, RuntimeError:
203
+ pass
204
+ await self._stopped.wait()
205
+
206
+ async def _ensure_workspace(self, identity: NodeIdentity) -> None:
207
+ self._identity = identity
208
+ workspace_dir = resolve_workspace_dir(identity.workspace_id)
209
+ await asyncio.to_thread(
210
+ workspace_dir.mkdir,
211
+ parents=True,
212
+ exist_ok=True,
213
+ mode=0o700,
214
+ )
215
+ if os.name != "nt":
216
+ await asyncio.to_thread(workspace_dir.chmod, 0o700)
217
+ await self._attachment_materializer.reconcile()
218
+
219
+ def _workspace_path(self) -> Path:
220
+ identity = self._identity
221
+ if identity is None:
222
+ raise RuntimeError("node identity has not been initialized")
223
+ return resolve_workspace_dir(identity.workspace_id)
224
+
225
+ async def _referenced_attachment_paths(self) -> set[str]:
226
+ async with self.storage.transaction() as transaction:
227
+ return set(await transaction.list_ready_attachment_paths())
228
+
229
+ def _adapter_context(self) -> Mapping[str, object]:
230
+ return {
231
+ "channel": self.channel,
232
+ "runtime": self.runtime,
233
+ "storage": self.storage,
234
+ "audit": self.audit,
235
+ "command_log": self.command_log,
236
+ "is_started": lambda: self._started,
237
+ }
238
+
239
+ async def _handle_control(
240
+ self, request: Mapping[str, object]
241
+ ) -> Mapping[str, object]:
242
+ if request.get("operation") == "health":
243
+ identity = self._identity
244
+ return {
245
+ "started": self._started,
246
+ "accepting": self.command_dispatcher.accepting,
247
+ "channel": self.channel.name,
248
+ "channel_health": dict(self.channel.health),
249
+ "runtime": self.runtime.name,
250
+ "storage": self.storage.name,
251
+ "audit": self.audit.name,
252
+ "node_id": identity.node_id if identity is not None else None,
253
+ "workspace_id": (
254
+ identity.workspace_id if identity is not None else None
255
+ ),
256
+ }
257
+ if request.get("operation") == "shutdown":
258
+ self._stopped.set()
259
+ return {"accepted": True, "operation": "shutdown"}
260
+ if self._provider_control_handler is None:
261
+ raise ValueError("control operation is not supported")
262
+ return await self._provider_control_handler(request)
263
+
264
+ async def _validate_session_binding(
265
+ self,
266
+ session_id: str,
267
+ request: Mapping[str, object],
268
+ ) -> None:
269
+ runtime_session_id = request.get("runtime_session_id")
270
+ session_capability = request.get("session_capability")
271
+ async with self.storage.transaction() as transaction:
272
+ bcn_session = await transaction.get_bcn_session(session_id)
273
+ if bcn_session is None:
274
+ raise CommandDispatchError(
275
+ "SESSION_NOT_FOUND",
276
+ f"unknown bcn session: {session_id}",
277
+ )
278
+ runtime_session = await transaction.find_runtime_session(session_id)
279
+ if runtime_session is None or runtime_session_id != (runtime_session.id):
280
+ raise CommandDispatchError(
281
+ "SESSION_BINDING_FAILED",
282
+ "runtime session binding is invalid",
283
+ )
284
+ expected_capability = self._session_capabilities.get(session_id)
285
+ if (
286
+ expected_capability is None
287
+ or not isinstance(session_capability, str)
288
+ or not hmac.compare_digest(
289
+ session_capability.encode(), expected_capability.encode()
290
+ )
291
+ ):
292
+ raise CommandDispatchError(
293
+ "SESSION_BINDING_FAILED",
294
+ "session capability is invalid",
295
+ )
296
+
297
+ def _runtime_environment(self, session: RuntimeSession) -> Mapping[str, str]:
298
+ self._runtime_session_ids[session.bcn_session_id] = session.id
299
+ return self._build_command_environment(
300
+ session.bcn_session_id,
301
+ session.id,
302
+ )
303
+
304
+ def _build_command_environment(
305
+ self,
306
+ session_id: str,
307
+ runtime_session_id: str,
308
+ ) -> dict[str, str]:
309
+ if not session_id:
310
+ raise ValueError("session_id must be a non-empty string")
311
+ if not runtime_session_id:
312
+ raise ValueError("runtime_session_id must be a non-empty string")
313
+ wrapper_path = self._wrapper_path
314
+ if wrapper_path is None:
315
+ raise RuntimeError("bcc wrapper is not installed")
316
+ session_capability = self._session_capabilities.setdefault(
317
+ session_id,
318
+ secrets.token_urlsafe(32),
319
+ )
320
+ wrapper_directory = str(wrapper_path.parent)
321
+ allowed = set(_PLATFORM_ENVIRONMENT)
322
+ for name in self.runtime.environment_variable_names():
323
+ if not _ENVIRONMENT_NAME.fullmatch(name):
324
+ raise ValueError(f"runtime environment name is invalid: {name}")
325
+ if name.startswith("BCN_") or name in _FORBIDDEN_ENVIRONMENT:
326
+ raise ValueError(f"runtime environment name is reserved: {name}")
327
+ allowed.add(name)
328
+ for name in self._runtime_environment_include:
329
+ if not _ENVIRONMENT_NAME.fullmatch(name):
330
+ raise ValueError(f"runtime environment name is invalid: {name}")
331
+ if name.startswith("BCN_") or name in _FORBIDDEN_ENVIRONMENT:
332
+ raise ValueError(f"runtime environment name is reserved: {name}")
333
+ if name not in os.environ:
334
+ raise ValueError(f"runtime environment variable is missing: {name}")
335
+ allowed.add(name)
336
+ environment = {
337
+ name: os.environ[name] for name in sorted(allowed) if os.environ.get(name)
338
+ }
339
+ environment["PATH"] = os.pathsep.join(
340
+ (wrapper_directory, environment.get("PATH", os.defpath))
341
+ )
342
+ environment.update(
343
+ {
344
+ "BCN_ENDPOINT": self.endpoint,
345
+ "BCN_SESSION_ID": session_id,
346
+ "BCN_RUNTIME_SESSION_ID": runtime_session_id,
347
+ "BCN_COMMAND_CAPABILITY": session_capability,
348
+ }
349
+ )
350
+ return environment
351
+
352
+ async def _run_runtime_command(
353
+ self,
354
+ session_id: str,
355
+ arguments: Sequence[str],
356
+ body: str | None,
357
+ ) -> None:
358
+ if not session_id:
359
+ raise ValueError("session_id must be a non-empty string")
360
+ if not arguments or any(
361
+ not isinstance(argument, str) for argument in arguments
362
+ ):
363
+ raise ValueError("runtime command arguments must be non-empty text")
364
+ wrapper_path = self._wrapper_path
365
+ if wrapper_path is None:
366
+ raise RuntimeError("bcc wrapper is not installed")
367
+ self.command_log.append((session_id, tuple(arguments)))
368
+ runtime_session_id = self._runtime_session_ids.get(
369
+ session_id,
370
+ f"runtime-{session_id}",
371
+ )
372
+ environment = self._build_command_environment(
373
+ session_id,
374
+ runtime_session_id,
375
+ )
376
+ process = await asyncio.create_subprocess_exec(
377
+ str(wrapper_path),
378
+ *arguments,
379
+ stdin=asyncio.subprocess.PIPE,
380
+ stdout=asyncio.subprocess.PIPE,
381
+ stderr=asyncio.subprocess.PIPE,
382
+ env=environment,
383
+ )
384
+ input_data = body.encode() if body is not None else None
385
+ try:
386
+ _stdout, stderr = await process.communicate(input=input_data)
387
+ except asyncio.CancelledError:
388
+ if process.returncode is None:
389
+ process.terminate()
390
+ await process.wait()
391
+ raise
392
+ if process.returncode != 0:
393
+ error = stderr.decode(errors="replace").strip()
394
+ command = " ".join(arguments)
395
+ raise RuntimeError(f"bcc command failed ({command}): {error}")
396
+
397
+
398
+ __all__ = ["CommandRecord", "NodeApplication"]
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import mimetypes
5
+ import os
6
+ import re
7
+ import shutil
8
+ from collections.abc import AsyncIterable, Awaitable, Callable
9
+ from pathlib import Path, PurePosixPath
10
+ from uuid import UUID, uuid7
11
+
12
+ from ..core.models import InboundAttachment
13
+
14
+ _SAFE_SUFFIX = re.compile(r"^\.[A-Za-z0-9]{1,10}$")
15
+
16
+
17
+ class AttachmentMaterializer:
18
+ def __init__(
19
+ self,
20
+ workspace: Callable[[], Path],
21
+ referenced_paths: Callable[[], Awaitable[set[str]]],
22
+ *,
23
+ max_file_bytes: int = 25 * 1024 * 1024,
24
+ max_workspace_bytes: int = 1024 * 1024 * 1024,
25
+ ) -> None:
26
+ self._workspace = workspace
27
+ self._referenced_paths = referenced_paths
28
+ self._max_file_bytes = max_file_bytes
29
+ self._max_workspace_bytes = max_workspace_bytes
30
+ self._lock = asyncio.Lock()
31
+
32
+ async def reconcile(self) -> None:
33
+ root = self._workspace() / "attachments"
34
+ staging = root / ".staging"
35
+ await asyncio.to_thread(staging.mkdir, parents=True, exist_ok=True, mode=0o700)
36
+ for path in await asyncio.to_thread(lambda: tuple(staging.iterdir())):
37
+ if path.is_file() and not path.is_symlink():
38
+ await asyncio.to_thread(path.unlink)
39
+ referenced = await self._referenced_paths()
40
+ for path in await asyncio.to_thread(lambda: tuple(root.iterdir())):
41
+ if path == staging or not path.is_dir() or path.is_symlink():
42
+ continue
43
+ try:
44
+ if UUID(path.name).version != 7:
45
+ continue
46
+ except ValueError:
47
+ continue
48
+ prefix = str(PurePosixPath("attachments", path.name)) + "/"
49
+ if any(relative_path.startswith(prefix) for relative_path in referenced):
50
+ continue
51
+ await asyncio.to_thread(shutil.rmtree, path)
52
+
53
+ async def materialize(
54
+ self,
55
+ source: bytes | AsyncIterable[bytes],
56
+ *,
57
+ name: str,
58
+ kind: str,
59
+ media_type: str | None = None,
60
+ ) -> InboundAttachment:
61
+ if not name or not kind:
62
+ raise ValueError("attachment name and kind must be non-empty")
63
+ attachment_id = str(uuid7())
64
+ suffix = Path(name).suffix
65
+ if not _SAFE_SUFFIX.fullmatch(suffix):
66
+ guessed = mimetypes.guess_extension(media_type or "") or ".bin"
67
+ suffix = guessed if _SAFE_SUFFIX.fullmatch(guessed) else ".bin"
68
+ relative = PurePosixPath(
69
+ "attachments", attachment_id, f"content{suffix.lower()}"
70
+ )
71
+ root = self._workspace() / "attachments"
72
+ staging = root / ".staging"
73
+ destination = self._workspace().joinpath(*relative.parts)
74
+ temporary = staging / f"{attachment_id}.part"
75
+ async with self._lock:
76
+ await asyncio.to_thread(
77
+ staging.mkdir, parents=True, exist_ok=True, mode=0o700
78
+ )
79
+ current_size = await asyncio.to_thread(self._stored_size, root)
80
+ size = 0
81
+ try:
82
+ with temporary.open("xb") as output:
83
+ if isinstance(source, bytes):
84
+ size = len(source)
85
+ if size > self._max_file_bytes:
86
+ raise ValueError(
87
+ "attachment exceeds the per-file size limit"
88
+ )
89
+ if current_size + size > self._max_workspace_bytes:
90
+ raise ValueError("attachment workspace quota exceeded")
91
+ await asyncio.to_thread(output.write, source)
92
+ else:
93
+ async for chunk in source:
94
+ if not isinstance(chunk, bytes):
95
+ raise TypeError("attachment stream must yield bytes")
96
+ size += len(chunk)
97
+ if size > self._max_file_bytes:
98
+ raise ValueError(
99
+ "attachment exceeds the per-file size limit"
100
+ )
101
+ if current_size + size > self._max_workspace_bytes:
102
+ raise ValueError("attachment workspace quota exceeded")
103
+ await asyncio.to_thread(output.write, chunk)
104
+ await asyncio.to_thread(output.flush)
105
+ await asyncio.to_thread(os.fsync, output.fileno())
106
+ await asyncio.to_thread(
107
+ destination.parent.mkdir, parents=True, mode=0o700
108
+ )
109
+ await asyncio.to_thread(os.replace, temporary, destination)
110
+ if os.name != "nt":
111
+ await asyncio.to_thread(destination.chmod, 0o600)
112
+ except BaseException:
113
+ if temporary.exists():
114
+ await asyncio.to_thread(temporary.unlink)
115
+ raise
116
+ return InboundAttachment(
117
+ attachment_id=attachment_id,
118
+ name=Path(name).name,
119
+ kind=kind,
120
+ state="ready",
121
+ media_type=media_type,
122
+ relative_path=str(relative),
123
+ size_bytes=size,
124
+ )
125
+
126
+ def failed(
127
+ self,
128
+ *,
129
+ name: str,
130
+ kind: str,
131
+ error: str,
132
+ media_type: str | None = None,
133
+ ) -> InboundAttachment:
134
+ return InboundAttachment(
135
+ attachment_id=str(uuid7()),
136
+ name=Path(name).name or "attachment.bin",
137
+ kind=kind,
138
+ state="failed",
139
+ media_type=media_type,
140
+ error=error,
141
+ )
142
+
143
+ @staticmethod
144
+ def _stored_size(root: Path) -> int:
145
+ if not root.exists():
146
+ return 0
147
+ return sum(
148
+ path.stat().st_size
149
+ for path in root.glob("*/content.*")
150
+ if path.is_file() and not path.is_symlink()
151
+ )
152
+
153
+
154
+ __all__ = ["AttachmentMaterializer"]