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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from time import time_ns
|
|
7
|
+
|
|
8
|
+
from ..core.command import (
|
|
9
|
+
ICommandService,
|
|
10
|
+
SessionNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from ..core.lifecycle import TimeoutBudget
|
|
13
|
+
from ..core.models import InboundMessage, OutboundMessage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def serialize_inbound(message: InboundMessage) -> dict[str, object]:
|
|
17
|
+
return {
|
|
18
|
+
"seq": message.seq,
|
|
19
|
+
"message_id": message.message_id,
|
|
20
|
+
"session_id": message.session_id,
|
|
21
|
+
"channel_session_id": message.channel_session_id,
|
|
22
|
+
"channel": message.channel,
|
|
23
|
+
"received_at_ms": message.received_at_ms,
|
|
24
|
+
"provider_time_ms": message.provider_time_ms,
|
|
25
|
+
"sender": message.sender,
|
|
26
|
+
"message_type": message.message_type,
|
|
27
|
+
"canonical_target": message.canonical_target,
|
|
28
|
+
"target_kind": message.target_kind.value,
|
|
29
|
+
"mentions_agent": message.mentions_agent,
|
|
30
|
+
"notifies_runtime": message.notifies_runtime,
|
|
31
|
+
"attachments": [
|
|
32
|
+
{
|
|
33
|
+
"attachment_id": attachment.attachment_id,
|
|
34
|
+
"name": attachment.name,
|
|
35
|
+
"kind": attachment.kind,
|
|
36
|
+
"state": attachment.state,
|
|
37
|
+
"media_type": attachment.media_type,
|
|
38
|
+
"relative_path": attachment.relative_path,
|
|
39
|
+
"size_bytes": attachment.size_bytes,
|
|
40
|
+
"error": attachment.error,
|
|
41
|
+
}
|
|
42
|
+
for attachment in message.attachments
|
|
43
|
+
],
|
|
44
|
+
"body": message.body,
|
|
45
|
+
"reply_to_message_id": message.reply_to_message_id,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serialize_outbound(message: OutboundMessage) -> dict[str, object]:
|
|
50
|
+
return {
|
|
51
|
+
"outbound_message_id": message.outbound_message_id,
|
|
52
|
+
"command_id": message.command_id,
|
|
53
|
+
"session_id": message.session_id,
|
|
54
|
+
"channel_session_id": message.channel_session_id,
|
|
55
|
+
"target": message.target,
|
|
56
|
+
"reply_to_message_id": message.reply_to_message_id,
|
|
57
|
+
"body": message.body,
|
|
58
|
+
"state": message.state.value,
|
|
59
|
+
"fresh_check_state": message.fresh_check_state.value,
|
|
60
|
+
"created_at_ms": message.created_at_ms,
|
|
61
|
+
"snapshot_seq": message.snapshot_seq,
|
|
62
|
+
"current_inbound_seq": message.current_inbound_seq,
|
|
63
|
+
"provider_attempted_at_ms": message.provider_attempted_at_ms,
|
|
64
|
+
"completed_at_ms": message.completed_at_ms,
|
|
65
|
+
"draft_saved_at_ms": message.draft_saved_at_ms,
|
|
66
|
+
"error_kind": message.error_kind,
|
|
67
|
+
"error_message": message.error_message,
|
|
68
|
+
"next_action": message.next_action,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CommandDispatchError(ValueError):
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
code: str,
|
|
76
|
+
message: str,
|
|
77
|
+
*,
|
|
78
|
+
draft_saved: bool = False,
|
|
79
|
+
next_action: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.code = code
|
|
83
|
+
self.message = message
|
|
84
|
+
self.draft_saved = draft_saved
|
|
85
|
+
self.next_action = next_action
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
ControlHandler = Callable[[Mapping[str, object]], Awaitable[Mapping[str, object]]]
|
|
89
|
+
SessionBindingValidator = Callable[[str, Mapping[str, object]], Awaitable[None]]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CommandDispatcher:
|
|
93
|
+
"""Translate local JSON requests into core command results."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
service: ICommandService,
|
|
98
|
+
*,
|
|
99
|
+
timeout_budget: TimeoutBudget,
|
|
100
|
+
control_handler: ControlHandler | None = None,
|
|
101
|
+
session_binding_validator: SessionBindingValidator | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._service = service
|
|
104
|
+
self._timeout_budget = timeout_budget
|
|
105
|
+
self._control_handler = control_handler
|
|
106
|
+
self._session_binding_validator = session_binding_validator
|
|
107
|
+
self._accepting = False
|
|
108
|
+
self._in_flight: set[asyncio.Task[object]] = set()
|
|
109
|
+
self._drained = asyncio.Event()
|
|
110
|
+
self._drained.set()
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def accepting(self) -> bool:
|
|
114
|
+
return self._accepting
|
|
115
|
+
|
|
116
|
+
def start_accepting(self) -> None:
|
|
117
|
+
self._accepting = True
|
|
118
|
+
self._drained.clear()
|
|
119
|
+
|
|
120
|
+
def stop_accepting(self) -> None:
|
|
121
|
+
self._accepting = False
|
|
122
|
+
if not self._in_flight:
|
|
123
|
+
self._drained.set()
|
|
124
|
+
|
|
125
|
+
async def drain(self, *, timeout: float) -> None:
|
|
126
|
+
if timeout <= 0:
|
|
127
|
+
raise ValueError("timeout must be positive")
|
|
128
|
+
self.stop_accepting()
|
|
129
|
+
if not self._in_flight:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
await asyncio.wait_for(asyncio.shield(self._drained.wait()), timeout)
|
|
133
|
+
return
|
|
134
|
+
except TimeoutError:
|
|
135
|
+
pass
|
|
136
|
+
current_task = asyncio.current_task()
|
|
137
|
+
pending = tuple(
|
|
138
|
+
task
|
|
139
|
+
for task in self._in_flight
|
|
140
|
+
if task is not current_task and not task.done()
|
|
141
|
+
)
|
|
142
|
+
for task in pending:
|
|
143
|
+
task.cancel()
|
|
144
|
+
if pending:
|
|
145
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
146
|
+
|
|
147
|
+
async def __call__(self, request: Mapping[str, object]) -> Mapping[str, object]:
|
|
148
|
+
if not self._accepting:
|
|
149
|
+
return {
|
|
150
|
+
"ok": False,
|
|
151
|
+
"code": "SERVICE_NOT_READY",
|
|
152
|
+
"error": "command service is not accepting requests",
|
|
153
|
+
}
|
|
154
|
+
current_task = asyncio.current_task()
|
|
155
|
+
if current_task is not None:
|
|
156
|
+
self._in_flight.add(current_task)
|
|
157
|
+
self._drained.clear()
|
|
158
|
+
try:
|
|
159
|
+
kind = request.get("kind")
|
|
160
|
+
if kind == "command":
|
|
161
|
+
async with asyncio.timeout(self._timeout_budget.command_seconds):
|
|
162
|
+
return await self._dispatch_command(request)
|
|
163
|
+
if kind == "control" and self._control_handler is not None:
|
|
164
|
+
result = await self._control_handler(request)
|
|
165
|
+
return {"ok": True, "result": dict(result)}
|
|
166
|
+
raise CommandDispatchError(
|
|
167
|
+
"INVALID_COMMAND", "request kind is not supported"
|
|
168
|
+
)
|
|
169
|
+
except asyncio.CancelledError:
|
|
170
|
+
raise
|
|
171
|
+
except CommandDispatchError as error:
|
|
172
|
+
response: dict[str, object] = {
|
|
173
|
+
"ok": False,
|
|
174
|
+
"code": error.code,
|
|
175
|
+
"error": error.message,
|
|
176
|
+
}
|
|
177
|
+
if error.draft_saved:
|
|
178
|
+
response["draft_saved"] = True
|
|
179
|
+
if error.next_action is not None:
|
|
180
|
+
response["next_action"] = error.next_action
|
|
181
|
+
return response
|
|
182
|
+
except SessionNotFoundError as error:
|
|
183
|
+
return {
|
|
184
|
+
"ok": False,
|
|
185
|
+
"code": "SESSION_NOT_FOUND",
|
|
186
|
+
"error": str(error),
|
|
187
|
+
}
|
|
188
|
+
except TimeoutError as error:
|
|
189
|
+
return {
|
|
190
|
+
"ok": False,
|
|
191
|
+
"code": "COMMAND_TIMEOUT",
|
|
192
|
+
"error": str(error) or "command timed out",
|
|
193
|
+
}
|
|
194
|
+
except ValueError as error:
|
|
195
|
+
return {
|
|
196
|
+
"ok": False,
|
|
197
|
+
"code": "INVALID_COMMAND",
|
|
198
|
+
"error": str(error),
|
|
199
|
+
}
|
|
200
|
+
except Exception as error: # noqa: BLE001
|
|
201
|
+
return {
|
|
202
|
+
"ok": False,
|
|
203
|
+
"code": "COMMAND_FAILED",
|
|
204
|
+
"error": str(error),
|
|
205
|
+
}
|
|
206
|
+
finally:
|
|
207
|
+
if current_task is not None:
|
|
208
|
+
self._in_flight.discard(current_task)
|
|
209
|
+
if not self._in_flight:
|
|
210
|
+
self._drained.set()
|
|
211
|
+
|
|
212
|
+
async def _dispatch_command(
|
|
213
|
+
self, request: Mapping[str, object]
|
|
214
|
+
) -> Mapping[str, object]:
|
|
215
|
+
session_id = request.get("session_id")
|
|
216
|
+
if not isinstance(session_id, str) or not session_id:
|
|
217
|
+
raise CommandDispatchError(
|
|
218
|
+
"SESSION_REQUIRED", "session_id must be a non-empty string"
|
|
219
|
+
)
|
|
220
|
+
command = request.get("command")
|
|
221
|
+
if not isinstance(command, str) or not command:
|
|
222
|
+
raise CommandDispatchError(
|
|
223
|
+
"COMMAND_REQUIRED", "command must be a non-empty string"
|
|
224
|
+
)
|
|
225
|
+
if self._session_binding_validator is not None:
|
|
226
|
+
await self._session_binding_validator(session_id, request)
|
|
227
|
+
|
|
228
|
+
if command == "check":
|
|
229
|
+
result = await self._service.check(session_id)
|
|
230
|
+
return {
|
|
231
|
+
"ok": True,
|
|
232
|
+
"result": {
|
|
233
|
+
"messages": [
|
|
234
|
+
serialize_inbound(message) for message in result.messages
|
|
235
|
+
],
|
|
236
|
+
"referenced_messages": [
|
|
237
|
+
serialize_inbound(message)
|
|
238
|
+
for message in result.referenced_messages
|
|
239
|
+
],
|
|
240
|
+
"snapshot_seq": result.snapshot_seq,
|
|
241
|
+
"delivered_through_seq": result.delivered_through_seq,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if command == "read":
|
|
246
|
+
target = request.get("target")
|
|
247
|
+
if not isinstance(target, str) or not target:
|
|
248
|
+
raise CommandDispatchError(
|
|
249
|
+
"TARGET_REQUIRED", "target must be a non-empty string"
|
|
250
|
+
)
|
|
251
|
+
around_message_id = request.get("around_message_id")
|
|
252
|
+
if around_message_id is not None and not isinstance(around_message_id, str):
|
|
253
|
+
raise CommandDispatchError(
|
|
254
|
+
"INVALID_AROUND_MESSAGE", "around_message_id must be a string"
|
|
255
|
+
)
|
|
256
|
+
limit = request.get("limit", 100)
|
|
257
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
|
258
|
+
raise CommandDispatchError(
|
|
259
|
+
"INVALID_LIMIT", "limit must be a positive integer"
|
|
260
|
+
)
|
|
261
|
+
result = await self._service.read(
|
|
262
|
+
session_id,
|
|
263
|
+
target=target,
|
|
264
|
+
around_message_id=around_message_id,
|
|
265
|
+
limit=limit,
|
|
266
|
+
)
|
|
267
|
+
return {
|
|
268
|
+
"ok": True,
|
|
269
|
+
"result": {
|
|
270
|
+
"messages": [
|
|
271
|
+
serialize_inbound(message) for message in result.messages
|
|
272
|
+
],
|
|
273
|
+
"referenced_messages": [
|
|
274
|
+
serialize_inbound(message)
|
|
275
|
+
for message in result.referenced_messages
|
|
276
|
+
],
|
|
277
|
+
"snapshot_seq": result.snapshot_seq,
|
|
278
|
+
"first_seq": result.first_seq,
|
|
279
|
+
"last_seq": result.last_seq,
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if command == "send":
|
|
284
|
+
target = request.get("target")
|
|
285
|
+
body = request.get("body")
|
|
286
|
+
command_id = request.get("command_id")
|
|
287
|
+
reply_to_message_id = request.get("reply_to_message_id")
|
|
288
|
+
created_at_ms = request.get("created_at_ms", time_ns() // 1_000_000)
|
|
289
|
+
if not isinstance(target, str) or not target:
|
|
290
|
+
raise CommandDispatchError(
|
|
291
|
+
"TARGET_REQUIRED", "target must be a non-empty string"
|
|
292
|
+
)
|
|
293
|
+
if not isinstance(body, str):
|
|
294
|
+
raise CommandDispatchError("BODY_REQUIRED", "body must be text")
|
|
295
|
+
if not isinstance(command_id, str) or not command_id:
|
|
296
|
+
raise CommandDispatchError(
|
|
297
|
+
"COMMAND_ID_REQUIRED", "command_id must be a non-empty string"
|
|
298
|
+
)
|
|
299
|
+
if reply_to_message_id is not None and (
|
|
300
|
+
not isinstance(reply_to_message_id, str) or not reply_to_message_id
|
|
301
|
+
):
|
|
302
|
+
raise CommandDispatchError(
|
|
303
|
+
"INVALID_REPLY_TO",
|
|
304
|
+
"reply_to_message_id must be a non-empty string",
|
|
305
|
+
)
|
|
306
|
+
if (
|
|
307
|
+
isinstance(created_at_ms, bool)
|
|
308
|
+
or not isinstance(created_at_ms, int)
|
|
309
|
+
or created_at_ms < 0
|
|
310
|
+
):
|
|
311
|
+
raise CommandDispatchError(
|
|
312
|
+
"INVALID_CREATED_AT", "created_at_ms must be non-negative"
|
|
313
|
+
)
|
|
314
|
+
result = await self._service.send(
|
|
315
|
+
session_id=session_id,
|
|
316
|
+
command_id=command_id,
|
|
317
|
+
target=target,
|
|
318
|
+
body=body,
|
|
319
|
+
created_at_ms=created_at_ms,
|
|
320
|
+
reply_to_message_id=reply_to_message_id,
|
|
321
|
+
)
|
|
322
|
+
return {
|
|
323
|
+
"ok": True,
|
|
324
|
+
"result": {"outbound": serialize_outbound(result)},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if command == "unfollow":
|
|
328
|
+
target = request.get("target")
|
|
329
|
+
if not isinstance(target, str) or not target:
|
|
330
|
+
raise CommandDispatchError(
|
|
331
|
+
"TARGET_REQUIRED", "target must be a non-empty string"
|
|
332
|
+
)
|
|
333
|
+
changed = await self._service.unfollow(session_id, target=target)
|
|
334
|
+
return {"ok": True, "result": {"changed": changed}}
|
|
335
|
+
|
|
336
|
+
raise CommandDispatchError("UNKNOWN_COMMAND", f"unsupported command: {command}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def format_message_time(timestamp_ms: int) -> str:
|
|
340
|
+
return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC).strftime(
|
|
341
|
+
"%Y-%m-%d %H:%M:%S"
|
|
342
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..core.paths import resolve_data_dir
|
|
8
|
+
from ..core.runtime import RuntimeSandboxMode
|
|
9
|
+
|
|
10
|
+
CONFIG_FILENAME = "config.toml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class NodeConfiguration:
|
|
15
|
+
"""Optional startup settings loaded from the node's persistent config."""
|
|
16
|
+
|
|
17
|
+
channel: str | None = None
|
|
18
|
+
runtime: str | None = None
|
|
19
|
+
storage: str | None = None
|
|
20
|
+
audit: str | None = None
|
|
21
|
+
endpoint: str | None = None
|
|
22
|
+
model: str | None = None
|
|
23
|
+
effort: str | None = None
|
|
24
|
+
sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE
|
|
25
|
+
network_access: bool = True
|
|
26
|
+
runtime_env_include: tuple[str, ...] = ()
|
|
27
|
+
wecom_bot_id: str | None = None
|
|
28
|
+
wecom_websocket_url: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigurationError(ValueError):
|
|
32
|
+
"""Raised when the persistent bcn configuration is invalid."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_config_path() -> Path:
|
|
36
|
+
return resolve_data_dir() / CONFIG_FILENAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_node_configuration() -> NodeConfiguration:
|
|
40
|
+
path = resolve_config_path()
|
|
41
|
+
try:
|
|
42
|
+
with path.open("rb") as config_file:
|
|
43
|
+
payload = tomllib.load(config_file)
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
return NodeConfiguration()
|
|
46
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
|
|
47
|
+
raise ConfigurationError(f"cannot read {path}: {error}") from error
|
|
48
|
+
|
|
49
|
+
node = payload.get("node", {})
|
|
50
|
+
if not isinstance(node, dict):
|
|
51
|
+
raise ConfigurationError("[node] must be a TOML table")
|
|
52
|
+
runtime = payload.get("runtime", {})
|
|
53
|
+
if not isinstance(runtime, dict):
|
|
54
|
+
raise ConfigurationError("[runtime] must be a TOML table")
|
|
55
|
+
runtime_env = runtime.get("env", {})
|
|
56
|
+
if not isinstance(runtime_env, dict):
|
|
57
|
+
raise ConfigurationError("[runtime.env] must be a TOML table")
|
|
58
|
+
channel = payload.get("channel", {})
|
|
59
|
+
if not isinstance(channel, dict):
|
|
60
|
+
raise ConfigurationError("[channel] must be a TOML table")
|
|
61
|
+
wecom = channel.get("wecom", {})
|
|
62
|
+
if not isinstance(wecom, dict):
|
|
63
|
+
raise ConfigurationError("[channel.wecom] must be a TOML table")
|
|
64
|
+
sandbox_mode = runtime.get("sandbox_mode", RuntimeSandboxMode.WORKSPACE_WRITE.value)
|
|
65
|
+
if not isinstance(sandbox_mode, str):
|
|
66
|
+
raise ConfigurationError("runtime.sandbox_mode must be text")
|
|
67
|
+
try:
|
|
68
|
+
parsed_sandbox_mode = RuntimeSandboxMode(sandbox_mode)
|
|
69
|
+
except ValueError as error:
|
|
70
|
+
allowed = ", ".join(mode.value for mode in RuntimeSandboxMode)
|
|
71
|
+
raise ConfigurationError(
|
|
72
|
+
f"runtime.sandbox_mode must be one of: {allowed}"
|
|
73
|
+
) from error
|
|
74
|
+
network_access = runtime.get("network_access", True)
|
|
75
|
+
if not isinstance(network_access, bool):
|
|
76
|
+
raise ConfigurationError("runtime.network_access must be a boolean")
|
|
77
|
+
return NodeConfiguration(
|
|
78
|
+
channel=_optional_text(node.get("channel"), "node.channel"),
|
|
79
|
+
runtime=_optional_text(node.get("runtime"), "node.runtime"),
|
|
80
|
+
storage=_optional_text(node.get("storage"), "node.storage"),
|
|
81
|
+
audit=_optional_text(node.get("audit"), "node.audit"),
|
|
82
|
+
endpoint=_optional_text(node.get("endpoint"), "node.endpoint"),
|
|
83
|
+
model=_optional_text(runtime.get("model"), "runtime.model"),
|
|
84
|
+
effort=_optional_text(runtime.get("effort"), "runtime.effort"),
|
|
85
|
+
sandbox_mode=parsed_sandbox_mode,
|
|
86
|
+
network_access=network_access,
|
|
87
|
+
runtime_env_include=_text_list(
|
|
88
|
+
runtime_env.get("include", []), "runtime.env.include"
|
|
89
|
+
),
|
|
90
|
+
wecom_bot_id=_optional_text(wecom.get("bot_id"), "channel.wecom.bot_id"),
|
|
91
|
+
wecom_websocket_url=_optional_text(
|
|
92
|
+
wecom.get("websocket_url"), "channel.wecom.websocket_url"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _optional_text(value: object, field_name: str) -> str | None:
|
|
98
|
+
if value is None:
|
|
99
|
+
return None
|
|
100
|
+
if not isinstance(value, str) or not value:
|
|
101
|
+
raise ConfigurationError(f"{field_name} must be non-empty text")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _text_list(value: object, field_name: str) -> tuple[str, ...]:
|
|
106
|
+
if not isinstance(value, list) or any(
|
|
107
|
+
not isinstance(item, str) or not item for item in value
|
|
108
|
+
):
|
|
109
|
+
raise ConfigurationError(f"{field_name} must be an array of non-empty text")
|
|
110
|
+
if len(set(value)) != len(value):
|
|
111
|
+
raise ConfigurationError(f"{field_name} cannot contain duplicates")
|
|
112
|
+
return tuple(value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = [
|
|
116
|
+
"CONFIG_FILENAME",
|
|
117
|
+
"ConfigurationError",
|
|
118
|
+
"NodeConfiguration",
|
|
119
|
+
"load_node_configuration",
|
|
120
|
+
"resolve_config_path",
|
|
121
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from ..core.channel import ChannelContext, IChannel
|
|
9
|
+
from ..core.observability import IAudit
|
|
10
|
+
from ..core.runtime import IRuntime, RuntimeCommandContext
|
|
11
|
+
from ..core.storage import IStorage
|
|
12
|
+
from .command import ControlHandler
|
|
13
|
+
|
|
14
|
+
CHANNEL_ENTRY_POINT_GROUP = "bazaar_compute_node.channels"
|
|
15
|
+
RUNTIME_ENTRY_POINT_GROUP = "bazaar_compute_node.runtimes"
|
|
16
|
+
STORAGE_ENTRY_POINT_GROUP = "bazaar_compute_node.storages"
|
|
17
|
+
AUDIT_ENTRY_POINT_GROUP = "bazaar_compute_node.audits"
|
|
18
|
+
CONTROL_ENTRY_POINT_GROUP = "bazaar_compute_node.controls"
|
|
19
|
+
|
|
20
|
+
ChannelFactory = Callable[[ChannelContext], IChannel]
|
|
21
|
+
RuntimeFactory = Callable[[RuntimeCommandContext], IRuntime]
|
|
22
|
+
StorageFactory = Callable[[], IStorage]
|
|
23
|
+
AuditFactory = Callable[[], IAudit]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class AdapterFactories:
|
|
28
|
+
channel: ChannelFactory
|
|
29
|
+
runtime: RuntimeFactory
|
|
30
|
+
storage: StorageFactory
|
|
31
|
+
audit: AuditFactory
|
|
32
|
+
control: Callable[[Mapping[str, object]], ControlHandler] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProviderLoadError(RuntimeError):
|
|
36
|
+
"""A selected provider is missing or has an invalid entry point."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AdapterRegistry:
|
|
40
|
+
"""Discover provider factories through Python package entry points."""
|
|
41
|
+
|
|
42
|
+
def load(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
channel: str,
|
|
46
|
+
runtime: str,
|
|
47
|
+
storage: str = "sqlite",
|
|
48
|
+
audit: str = "logging",
|
|
49
|
+
) -> AdapterFactories:
|
|
50
|
+
control = self._load_optional(
|
|
51
|
+
CONTROL_ENTRY_POINT_GROUP,
|
|
52
|
+
f"{channel}+{runtime}+{storage}",
|
|
53
|
+
)
|
|
54
|
+
return AdapterFactories(
|
|
55
|
+
channel=cast(
|
|
56
|
+
ChannelFactory,
|
|
57
|
+
self._load(CHANNEL_ENTRY_POINT_GROUP, channel),
|
|
58
|
+
),
|
|
59
|
+
runtime=cast(
|
|
60
|
+
RuntimeFactory,
|
|
61
|
+
self._load(RUNTIME_ENTRY_POINT_GROUP, runtime),
|
|
62
|
+
),
|
|
63
|
+
storage=cast(
|
|
64
|
+
StorageFactory,
|
|
65
|
+
self._load(STORAGE_ENTRY_POINT_GROUP, storage),
|
|
66
|
+
),
|
|
67
|
+
audit=cast(
|
|
68
|
+
AuditFactory,
|
|
69
|
+
self._load(AUDIT_ENTRY_POINT_GROUP, audit),
|
|
70
|
+
),
|
|
71
|
+
control=cast(
|
|
72
|
+
Callable[[Mapping[str, object]], ControlHandler] | None,
|
|
73
|
+
control,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _load(self, group: str, name: str) -> Any:
|
|
78
|
+
entry_point = self._find(group, name)
|
|
79
|
+
if entry_point is None:
|
|
80
|
+
raise ProviderLoadError(
|
|
81
|
+
f"provider '{name}' is not installed for entry point group '{group}'"
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
factory = entry_point.load()
|
|
85
|
+
except Exception as error:
|
|
86
|
+
raise ProviderLoadError(
|
|
87
|
+
f"failed to load provider '{name}' from '{group}': {error}"
|
|
88
|
+
) from error
|
|
89
|
+
if not callable(factory):
|
|
90
|
+
raise ProviderLoadError(f"provider '{name}' from '{group}' is not callable")
|
|
91
|
+
return factory
|
|
92
|
+
|
|
93
|
+
def _load_optional(self, group: str, name: str) -> Any | None:
|
|
94
|
+
entry_point = self._find(group, name)
|
|
95
|
+
if entry_point is None:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
factory = entry_point.load()
|
|
99
|
+
except Exception as error:
|
|
100
|
+
raise ProviderLoadError(
|
|
101
|
+
f"failed to load optional provider '{name}' from '{group}': {error}"
|
|
102
|
+
) from error
|
|
103
|
+
if not callable(factory):
|
|
104
|
+
raise ProviderLoadError(
|
|
105
|
+
f"optional provider '{name}' from '{group}' is not callable"
|
|
106
|
+
)
|
|
107
|
+
return factory
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _find(group: str, name: str) -> EntryPoint | None:
|
|
111
|
+
if not name:
|
|
112
|
+
raise ProviderLoadError(f"provider name for '{group}' is empty")
|
|
113
|
+
return next(
|
|
114
|
+
(
|
|
115
|
+
candidate
|
|
116
|
+
for candidate in entry_points(group=group)
|
|
117
|
+
if candidate.name == name
|
|
118
|
+
),
|
|
119
|
+
None,
|
|
120
|
+
)
|