bazaar-compute-node 0.1.4__tar.gz → 0.1.6__tar.gz

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 (64) hide show
  1. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/PKG-INFO +4 -2
  2. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/README.md +3 -1
  3. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/pyproject.toml +3 -2
  4. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/pyproject.toml.orig +7 -6
  5. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/application.py +10 -7
  6. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/attachments.py +52 -16
  7. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/command.py +19 -0
  8. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/config.py +12 -4
  9. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/registry.py +9 -4
  10. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/transport.py +16 -10
  11. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/windows_pipe.py +4 -2
  12. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/bcc.py +5 -0
  13. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/cli.py +46 -10
  14. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/__init__.py +4 -0
  15. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/client.py +34 -0
  16. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/runtime.py +52 -1
  17. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/sqlite/codec.py +33 -0
  18. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/sqlite/database.py +16 -4
  19. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/sqlite/migrations.py +12 -0
  20. bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/sqlite/plugin.py +16 -0
  21. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/sqlite/repository.py +18 -1
  22. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/wecom/channel.py +400 -45
  23. bazaar_compute_node-0.1.6/src/bazaar_compute_node/contrib/wecom/outbound.py +193 -0
  24. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/command.py +1 -0
  25. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/instruction.py +3 -0
  26. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/models/__init__.py +2 -0
  27. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/models/entities.py +40 -0
  28. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/orchestration/command.py +130 -18
  29. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/orchestration/session.py +110 -4
  30. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/orchestration/turn.py +55 -3
  31. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/paths.py +14 -1
  32. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/runtime.py +16 -0
  33. bazaar_compute_node-0.1.4/src/bazaar_compute_node/contrib/sqlite/plugin.py +0 -11
  34. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/LICENSE +0 -0
  35. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/__init__.py +0 -0
  36. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/__init__.py +0 -0
  37. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/app/wrapper.py +0 -0
  38. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  39. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/approval.py +0 -0
  40. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/events.py +0 -0
  41. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/plugin.py +0 -0
  42. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/process.py +0 -0
  43. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/codex_app_server/protocol.py +0 -0
  44. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  45. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  46. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  47. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/sqlite/__init__.py +0 -0
  48. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  49. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  50. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
  51. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/__init__.py +0 -0
  52. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/approval.py +0 -0
  53. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/audit.py +0 -0
  54. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/channel.py +0 -0
  55. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/client.py +0 -0
  56. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/concurrency.py +0 -0
  57. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/correlation.py +0 -0
  58. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  59. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/models/states.py +0 -0
  60. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/observability.py +0 -0
  61. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/orchestration/__init__.py +0 -0
  62. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/orchestration/services.py +0 -0
  63. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/outcomes.py +0 -0
  64. {bazaar_compute_node-0.1.4 → bazaar_compute_node-0.1.6}/src/bazaar_compute_node/core/storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Runtime-agnostic computer node daemon for agents and channels.
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -167,7 +167,9 @@ uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn restar
167
167
  uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn --help
168
168
  ```
169
169
 
170
- 节点数据默认保存在 `~/.bcn`。停止节点会优雅关闭后台进程,不会删除既有会话、工作区或消息记录。
170
+ 节点数据默认保存在 `~/.bcn`。可通过 `BCN_DATA_NAME` 指定 HOME 下的另一数据目录名称,
171
+ 例如 `BCN_DATA_NAME=.bcn-test`;该值必须是单个目录名。停止节点会优雅关闭后台进程,
172
+ 不会删除既有会话、工作区或消息记录。
171
173
 
172
174
  ## 许可证
173
175
 
@@ -154,7 +154,9 @@ uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn restar
154
154
  uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn --help
155
155
  ```
156
156
 
157
- 节点数据默认保存在 `~/.bcn`。停止节点会优雅关闭后台进程,不会删除既有会话、工作区或消息记录。
157
+ 节点数据默认保存在 `~/.bcn`。可通过 `BCN_DATA_NAME` 指定 HOME 下的另一数据目录名称,
158
+ 例如 `BCN_DATA_NAME=.bcn-test`;该值必须是单个目录名。停止节点会优雅关闭后台进程,
159
+ 不会删除既有会话、工作区或消息记录。
158
160
 
159
161
  ## 许可证
160
162
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -43,7 +43,8 @@ path = "tests/support"
43
43
  editable = true
44
44
 
45
45
  [tool.pytest.ini_options]
46
- markers = ["real_home: run against the actual user home and persistent node data directory"]
46
+ addopts = "-m 'not e2e'"
47
+ markers = ["e2e: requires a real external provider"]
47
48
 
48
49
  [build-system]
49
50
  requires = ["uv_build>=0.12.2,<0.13.0"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -41,11 +41,12 @@ dev = [
41
41
  [tool.uv.sources]
42
42
  bcn-test-support = { path = "tests/support", editable = true }
43
43
 
44
- [build-system]
45
- requires = ["uv_build>=0.12.2,<0.13.0"]
46
- build-backend = "uv_build"
47
-
48
44
  [tool.pytest.ini_options]
45
+ addopts = "-m 'not e2e'"
49
46
  markers = [
50
- "real_home: run against the actual user home and persistent node data directory",
47
+ "e2e: requires a real external provider",
51
48
  ]
49
+
50
+ [build-system]
51
+ requires = ["uv_build>=0.12.2,<0.13.0"]
52
+ build-backend = "uv_build"
@@ -114,6 +114,7 @@ class NodeApplication:
114
114
  storage=self.storage,
115
115
  audit=self.audit,
116
116
  timeout_budget=self.timeout_budget,
117
+ workspace=self._workspace_path,
117
118
  on_node_initialized=self._ensure_workspace,
118
119
  )
119
120
  self.command_service = self.orchestrator.command_service
@@ -139,10 +140,12 @@ class NodeApplication:
139
140
  async def start(self) -> None:
140
141
  if self._started:
141
142
  return
142
- self.data_dir.mkdir(parents=True, exist_ok=True)
143
+ await asyncio.to_thread(self.data_dir.mkdir, parents=True, exist_ok=True)
143
144
  if os.name != "nt":
144
- self.data_dir.chmod(0o700)
145
- self._wrapper_path = install_bcc_wrapper(self.data_dir / "bin")
145
+ await asyncio.to_thread(self.data_dir.chmod, 0o700)
146
+ self._wrapper_path = await asyncio.to_thread(
147
+ install_bcc_wrapper, self.data_dir / "bin"
148
+ )
146
149
  try:
147
150
  await self.command_server.start()
148
151
  await self.orchestrator.start(
@@ -165,7 +168,7 @@ class NodeApplication:
165
168
  try:
166
169
  await self.command_server.stop()
167
170
  finally:
168
- self._cleanup_bcc_wrapper()
171
+ await self._cleanup_bcc_wrapper()
169
172
  return
170
173
  self._started = False
171
174
  self.command_dispatcher.stop_accepting()
@@ -183,15 +186,15 @@ class NodeApplication:
183
186
  await self.command_server.stop()
184
187
  finally:
185
188
  try:
186
- self._cleanup_bcc_wrapper()
189
+ await self._cleanup_bcc_wrapper()
187
190
  finally:
188
191
  self._stopped.set()
189
192
 
190
- def _cleanup_bcc_wrapper(self) -> None:
193
+ async def _cleanup_bcc_wrapper(self) -> None:
191
194
  wrapper_path = self._wrapper_path
192
195
  if wrapper_path is None:
193
196
  return
194
- remove_bcc_wrapper(wrapper_path)
197
+ await asyncio.to_thread(remove_bcc_wrapper, wrapper_path)
195
198
  self._wrapper_path = None
196
199
 
197
200
  async def wait(self) -> None:
@@ -33,13 +33,24 @@ class AttachmentMaterializer:
33
33
  root = self._workspace() / "attachments"
34
34
  staging = root / ".staging"
35
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)
36
+ staged_files = await asyncio.to_thread(
37
+ lambda: tuple(
38
+ path
39
+ for path in staging.iterdir()
40
+ if path.is_file() and not path.is_symlink()
41
+ )
42
+ )
43
+ for path in staged_files:
44
+ await asyncio.to_thread(path.unlink)
39
45
  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
46
+ attachment_directories = await asyncio.to_thread(
47
+ lambda: tuple(
48
+ path
49
+ for path in root.iterdir()
50
+ if path != staging and path.is_dir() and not path.is_symlink()
51
+ )
52
+ )
53
+ for path in attachment_directories:
43
54
  try:
44
55
  if UUID(path.name).version != 7:
45
56
  continue
@@ -61,10 +72,7 @@ class AttachmentMaterializer:
61
72
  if not name or not kind:
62
73
  raise ValueError("attachment name and kind must be non-empty")
63
74
  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"
75
+ suffix = await asyncio.to_thread(_attachment_suffix, name, media_type)
68
76
  relative = PurePosixPath(
69
77
  "attachments", attachment_id, f"content{suffix.lower()}"
70
78
  )
@@ -78,8 +86,15 @@ class AttachmentMaterializer:
78
86
  )
79
87
  current_size = await asyncio.to_thread(self._stored_size, root)
80
88
  size = 0
89
+ descriptor: int | None = None
81
90
  try:
82
- with temporary.open("xb") as output:
91
+ descriptor = await asyncio.to_thread(
92
+ os.open,
93
+ temporary,
94
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
95
+ 0o600,
96
+ )
97
+ try:
83
98
  if isinstance(source, bytes):
84
99
  size = len(source)
85
100
  if size > self._max_file_bytes:
@@ -88,7 +103,7 @@ class AttachmentMaterializer:
88
103
  )
89
104
  if current_size + size > self._max_workspace_bytes:
90
105
  raise ValueError("attachment workspace quota exceeded")
91
- await asyncio.to_thread(output.write, source)
106
+ await asyncio.to_thread(_write_all, descriptor, source)
92
107
  else:
93
108
  async for chunk in source:
94
109
  if not isinstance(chunk, bytes):
@@ -100,9 +115,11 @@ class AttachmentMaterializer:
100
115
  )
101
116
  if current_size + size > self._max_workspace_bytes:
102
117
  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())
118
+ await asyncio.to_thread(_write_all, descriptor, chunk)
119
+ await asyncio.to_thread(os.fsync, descriptor)
120
+ finally:
121
+ await asyncio.to_thread(os.close, descriptor)
122
+ descriptor = None
106
123
  await asyncio.to_thread(
107
124
  destination.parent.mkdir, parents=True, mode=0o700
108
125
  )
@@ -110,7 +127,9 @@ class AttachmentMaterializer:
110
127
  if os.name != "nt":
111
128
  await asyncio.to_thread(destination.chmod, 0o600)
112
129
  except BaseException:
113
- if temporary.exists():
130
+ if descriptor is not None:
131
+ await asyncio.to_thread(os.close, descriptor)
132
+ if await asyncio.to_thread(temporary.exists):
114
133
  await asyncio.to_thread(temporary.unlink)
115
134
  raise
116
135
  return InboundAttachment(
@@ -151,4 +170,21 @@ class AttachmentMaterializer:
151
170
  )
152
171
 
153
172
 
173
+ def _write_all(descriptor: int, payload: bytes) -> None:
174
+ view = memoryview(payload)
175
+ while view:
176
+ written = os.write(descriptor, view)
177
+ if written == 0:
178
+ raise OSError("attachment write made no progress")
179
+ view = view[written:]
180
+
181
+
182
+ def _attachment_suffix(name: str, media_type: str | None) -> str:
183
+ suffix = Path(name).suffix
184
+ if _SAFE_SUFFIX.fullmatch(suffix):
185
+ return suffix
186
+ guessed = mimetypes.guess_extension(media_type or "") or ".bin"
187
+ return guessed if _SAFE_SUFFIX.fullmatch(guessed) else ".bin"
188
+
189
+
154
190
  __all__ = ["AttachmentMaterializer"]
@@ -55,6 +55,16 @@ def serialize_outbound(message: OutboundMessage) -> dict[str, object]:
55
55
  "target": message.target,
56
56
  "reply_to_message_id": message.reply_to_message_id,
57
57
  "body": message.body,
58
+ "attachments": [
59
+ {
60
+ "name": attachment.name,
61
+ "relative_path": attachment.relative_path,
62
+ "media_type": attachment.media_type,
63
+ "size_bytes": attachment.size_bytes,
64
+ "sha256": attachment.sha256,
65
+ }
66
+ for attachment in message.attachments
67
+ ],
58
68
  "state": message.state.value,
59
69
  "fresh_check_state": message.fresh_check_state.value,
60
70
  "created_at_ms": message.created_at_ms,
@@ -284,6 +294,7 @@ class CommandDispatcher:
284
294
  target = request.get("target")
285
295
  body = request.get("body")
286
296
  command_id = request.get("command_id")
297
+ attachment_paths = request.get("attachment_paths", [])
287
298
  reply_to_message_id = request.get("reply_to_message_id")
288
299
  created_at_ms = request.get("created_at_ms", time_ns() // 1_000_000)
289
300
  if not isinstance(target, str) or not target:
@@ -296,6 +307,13 @@ class CommandDispatcher:
296
307
  raise CommandDispatchError(
297
308
  "COMMAND_ID_REQUIRED", "command_id must be a non-empty string"
298
309
  )
310
+ if not isinstance(attachment_paths, list) or not all(
311
+ isinstance(path, str) and path for path in attachment_paths
312
+ ):
313
+ raise CommandDispatchError(
314
+ "INVALID_ATTACHMENTS",
315
+ "attachment_paths must be a list of non-empty strings",
316
+ )
299
317
  if reply_to_message_id is not None and (
300
318
  not isinstance(reply_to_message_id, str) or not reply_to_message_id
301
319
  ):
@@ -317,6 +335,7 @@ class CommandDispatcher:
317
335
  target=target,
318
336
  body=body,
319
337
  created_at_ms=created_at_ms,
338
+ attachment_paths=tuple(attachment_paths),
320
339
  reply_to_message_id=reply_to_message_id,
321
340
  )
322
341
  return {
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import tomllib
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
@@ -19,6 +20,7 @@ class NodeConfiguration:
19
20
  storage: str | None = None
20
21
  audit: str | None = None
21
22
  endpoint: str | None = None
23
+ database_name: str | None = None
22
24
  model: str | None = None
23
25
  effort: str | None = None
24
26
  sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE
@@ -36,13 +38,18 @@ def resolve_config_path() -> Path:
36
38
  return resolve_data_dir() / CONFIG_FILENAME
37
39
 
38
40
 
39
- def load_node_configuration() -> NodeConfiguration:
40
- path = resolve_config_path()
41
+ def load_node_configuration(path: Path | None = None) -> NodeConfiguration:
42
+ path = path or resolve_config_path()
41
43
  try:
44
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
45
+ try:
46
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
47
+ except FileExistsError:
48
+ pass
49
+ else:
50
+ os.close(descriptor)
42
51
  with path.open("rb") as config_file:
43
52
  payload = tomllib.load(config_file)
44
- except FileNotFoundError:
45
- return NodeConfiguration()
46
53
  except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
47
54
  raise ConfigurationError(f"cannot read {path}: {error}") from error
48
55
 
@@ -80,6 +87,7 @@ def load_node_configuration() -> NodeConfiguration:
80
87
  storage=_optional_text(node.get("storage"), "node.storage"),
81
88
  audit=_optional_text(node.get("audit"), "node.audit"),
82
89
  endpoint=_optional_text(node.get("endpoint"), "node.endpoint"),
90
+ database_name=_optional_text(node.get("database_name"), "node.database_name"),
83
91
  model=_optional_text(runtime.get("model"), "runtime.model"),
84
92
  effort=_optional_text(runtime.get("effort"), "runtime.effort"),
85
93
  sandbox_mode=parsed_sandbox_mode,
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from collections.abc import Callable, Mapping
4
4
  from dataclasses import dataclass
5
+ from functools import partial
5
6
  from importlib.metadata import EntryPoint, entry_points
6
7
  from typing import Any, cast
7
8
 
@@ -46,11 +47,18 @@ class AdapterRegistry:
46
47
  runtime: str,
47
48
  storage: str = "sqlite",
48
49
  audit: str = "logging",
50
+ storage_options: Mapping[str, object] | None = None,
49
51
  ) -> AdapterFactories:
50
52
  control = self._load_optional(
51
53
  CONTROL_ENTRY_POINT_GROUP,
52
54
  f"{channel}+{runtime}+{storage}",
53
55
  )
56
+ storage_factory = cast(
57
+ Callable[[Mapping[str, object]], IStorage] | StorageFactory,
58
+ self._load(STORAGE_ENTRY_POINT_GROUP, storage),
59
+ )
60
+ if storage_options:
61
+ storage_factory = partial(storage_factory, dict(storage_options))
54
62
  return AdapterFactories(
55
63
  channel=cast(
56
64
  ChannelFactory,
@@ -60,10 +68,7 @@ class AdapterRegistry:
60
68
  RuntimeFactory,
61
69
  self._load(RUNTIME_ENTRY_POINT_GROUP, runtime),
62
70
  ),
63
- storage=cast(
64
- StorageFactory,
65
- self._load(STORAGE_ENTRY_POINT_GROUP, storage),
66
- ),
71
+ storage=cast(StorageFactory, storage_factory),
67
72
  audit=cast(
68
73
  AuditFactory,
69
74
  self._load(AUDIT_ENTRY_POINT_GROUP, audit),
@@ -84,21 +84,23 @@ class LocalCommandServer:
84
84
 
85
85
  path = self._endpoint_path
86
86
  if path is None:
87
- path = (
88
- Path(tempfile.gettempdir())
89
- / f"bcn-{os.getpid()}-{secrets.token_hex(6)}.sock"
87
+ path = await asyncio.to_thread(
88
+ lambda: (
89
+ Path(tempfile.gettempdir())
90
+ / f"bcn-{os.getpid()}-{secrets.token_hex(6)}.sock"
91
+ )
90
92
  )
91
93
  path = path.expanduser()
92
- path.parent.mkdir(parents=True, exist_ok=True)
93
- if path.exists():
94
+ await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
95
+ if await asyncio.to_thread(path.exists):
94
96
  raise FileExistsError(f"local command endpoint already exists: {path}")
95
97
  self._server = await asyncio.start_unix_server(
96
98
  self._handle_client,
97
99
  path=str(path),
98
100
  )
99
- os.chmod(path, 0o600)
101
+ await asyncio.to_thread(os.chmod, path, 0o600)
100
102
  self._unix_path = path
101
- path_stat = path.stat()
103
+ path_stat = await asyncio.to_thread(path.stat)
102
104
  self._unix_identity = (path_stat.st_dev, path_stat.st_ino)
103
105
  self._endpoint = f"unix://{path}"
104
106
 
@@ -122,10 +124,14 @@ class LocalCommandServer:
122
124
  self._unix_path = None
123
125
  identity = self._unix_identity
124
126
  self._unix_identity = None
125
- if path is not None and identity is not None and path.exists():
126
- path_stat = path.stat()
127
+ if (
128
+ path is not None
129
+ and identity is not None
130
+ and await asyncio.to_thread(path.exists)
131
+ ):
132
+ path_stat = await asyncio.to_thread(path.stat)
127
133
  if (path_stat.st_dev, path_stat.st_ino) == identity:
128
- path.unlink()
134
+ await asyncio.to_thread(path.unlink)
129
135
 
130
136
  async def _handle_client(
131
137
  self,
@@ -12,6 +12,8 @@ from pathlib import Path
12
12
  from typing import Any
13
13
  from urllib.parse import urlsplit
14
14
 
15
+ from bazaar_compute_node.core.paths import resolve_data_dir
16
+
15
17
  RequestHandler = Callable[[Mapping[str, object]], Awaitable[Mapping[str, object]]]
16
18
 
17
19
  _ERROR_ALREADY_EXISTS = 183
@@ -122,8 +124,8 @@ def _is_invalid_handle(handle: Any) -> bool:
122
124
 
123
125
 
124
126
  def _pipe_name(endpoint_path: Path | None) -> str:
125
- path = endpoint_path or (Path.home() / ".bcn" / "bcn.sock")
126
- identity = str(path.expanduser().resolve(strict=False)).casefold()
127
+ path = endpoint_path or (resolve_data_dir() / "bcn.sock")
128
+ identity = str(path.expanduser().absolute()).casefold()
127
129
  digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32]
128
130
  return f"bcn-{digest}"
129
131
 
@@ -5,6 +5,7 @@ import asyncio
5
5
  import os
6
6
  import sys
7
7
  from collections.abc import Mapping, Sequence
8
+ from pathlib import Path
8
9
  from typing import NoReturn
9
10
  from uuid import uuid7
10
11
 
@@ -46,6 +47,7 @@ def build_parser() -> argparse.ArgumentParser:
46
47
  send_parser = message_subparsers.add_parser("send")
47
48
  send_parser.add_argument("--target", required=True)
48
49
  send_parser.add_argument("--reply-to")
50
+ send_parser.add_argument("--attachment", action="append", default=[])
49
51
 
50
52
  thread_parser = subparsers.add_parser("thread")
51
53
  thread_subparsers = thread_parser.add_subparsers(dest="command", required=True)
@@ -100,6 +102,9 @@ async def _request(
100
102
  request["body"] = body if body is not None else ""
101
103
  request["command_id"] = f"bcc-{uuid7().hex}"
102
104
  request["reply_to_message_id"] = args.reply_to
105
+ request["attachment_paths"] = await asyncio.to_thread(
106
+ lambda: [str(Path(path).absolute()) for path in args.attachment]
107
+ )
103
108
  elif args.command == "unfollow":
104
109
  request["target"] = args.target
105
110
 
@@ -66,6 +66,16 @@ def build_parser() -> argparse.ArgumentParser:
66
66
  )
67
67
  parser.add_argument("--storage")
68
68
  parser.add_argument("--audit")
69
+ parser.add_argument(
70
+ "--config",
71
+ type=Path,
72
+ help="Configuration file path; defaults to the node data directory.",
73
+ )
74
+ parser.add_argument(
75
+ "--database-name",
76
+ type=_database_name,
77
+ help="SQLite database filename under the node data directory.",
78
+ )
69
79
  parser.add_argument(
70
80
  "--endpoint",
71
81
  type=Path,
@@ -85,6 +95,14 @@ def _non_empty_option(value: str) -> str:
85
95
  return value
86
96
 
87
97
 
98
+ def _database_name(value: str) -> str:
99
+ if not value or value in {".", ".."} or "/" in value or "\\" in value:
100
+ raise argparse.ArgumentTypeError(
101
+ "database name must be a single non-empty path component"
102
+ )
103
+ return value
104
+
105
+
88
106
  def _endpoint_path(args: argparse.Namespace, data_dir: Path) -> Path:
89
107
  return (args.endpoint or data_dir / "bcn.sock").expanduser()
90
108
 
@@ -107,6 +125,9 @@ def _load_factories(
107
125
  runtime=args.runtime,
108
126
  storage=args.storage,
109
127
  audit=args.audit,
128
+ storage_options={"database_name": args.database_name}
129
+ if args.storage == "sqlite" and args.database_name is not None
130
+ else None,
110
131
  )
111
132
  except ProviderLoadError as error:
112
133
  parser.error(str(error))
@@ -128,7 +149,7 @@ def _apply_runtime_configuration(
128
149
  parser: argparse.ArgumentParser,
129
150
  ) -> None:
130
151
  try:
131
- configuration = load_node_configuration()
152
+ configuration = load_node_configuration(args.config)
132
153
  except ConfigurationError as error:
133
154
  parser.error(str(error))
134
155
  for name in (
@@ -145,6 +166,8 @@ def _apply_runtime_configuration(
145
166
  setattr(args, name, getattr(configuration, name))
146
167
  if args.endpoint is None and configuration.endpoint is not None:
147
168
  args.endpoint = Path(configuration.endpoint).expanduser()
169
+ if args.database_name is None:
170
+ args.database_name = configuration.database_name
148
171
  if args.storage is None:
149
172
  args.storage = DEFAULT_STORAGE
150
173
  if args.audit is None:
@@ -157,7 +180,7 @@ def _apply_runtime_configuration(
157
180
 
158
181
 
159
182
  async def _run_node(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
160
- factories = _load_factories(args, parser)
183
+ factories = await asyncio.to_thread(_load_factories, args, parser)
161
184
 
162
185
  data_dir = resolve_data_dir()
163
186
  node = NodeApplication(
@@ -198,6 +221,10 @@ def _daemon_command(args: argparse.Namespace, data_dir: Path) -> list[str]:
198
221
  "--endpoint",
199
222
  str(_endpoint_path(args, data_dir)),
200
223
  ]
224
+ if args.config is not None:
225
+ command.extend(("--config", str(args.config)))
226
+ if args.database_name is not None:
227
+ command.extend(("--database-name", args.database_name))
201
228
  for name in ("model", "effort", "sandbox_mode"):
202
229
  value = getattr(args, name)
203
230
  if value is not None:
@@ -284,14 +311,14 @@ async def _start_daemon(
284
311
  args: argparse.Namespace,
285
312
  parser: argparse.ArgumentParser,
286
313
  ) -> int:
287
- _load_factories(args, parser)
288
- data_dir = resolve_data_dir()
289
- data_dir.mkdir(parents=True, exist_ok=True)
314
+ await asyncio.to_thread(_load_factories, args, parser)
315
+ data_dir = await asyncio.to_thread(resolve_data_dir)
316
+ await asyncio.to_thread(data_dir.mkdir, parents=True, exist_ok=True)
290
317
  endpoint_path = _endpoint_path(args, data_dir)
291
318
  endpoint = local_endpoint_for_path(endpoint_path)
292
319
  if os.name == "nt" and await _endpoint_is_reachable(endpoint, timeout=0.5):
293
320
  parser.error("bcn is already running")
294
- if os.name != "nt" and endpoint_path.exists():
321
+ if os.name != "nt" and await asyncio.to_thread(endpoint_path.exists):
295
322
  parser.error(f"bcn endpoint already exists: {endpoint_path}")
296
323
 
297
324
  log_path = data_dir / "bcn.log"
@@ -316,7 +343,7 @@ async def _stop_daemon(
316
343
  args: argparse.Namespace,
317
344
  parser: argparse.ArgumentParser,
318
345
  ) -> int:
319
- data_dir = resolve_data_dir()
346
+ data_dir = await asyncio.to_thread(resolve_data_dir)
320
347
  endpoint_path = _endpoint_path(args, data_dir)
321
348
  endpoint = local_endpoint_for_path(endpoint_path)
322
349
  if not await _endpoint_is_reachable(endpoint, timeout=0.5):
@@ -354,10 +381,8 @@ async def _restart_daemon(
354
381
 
355
382
 
356
383
  async def async_main(argv: Sequence[str] | None = None) -> int:
357
- parser = build_parser()
358
- args = parser.parse_args(argv)
384
+ parser, args = await asyncio.to_thread(_prepare_cli_arguments, argv)
359
385
  command = args.command
360
- _apply_runtime_configuration(args, parser)
361
386
  if command is None:
362
387
  if args.channel is None and args.runtime is None:
363
388
  parser.print_help()
@@ -374,6 +399,17 @@ async def async_main(argv: Sequence[str] | None = None) -> int:
374
399
  parser.error(f"unsupported command: {command}")
375
400
 
376
401
 
402
+ def _prepare_cli_arguments(
403
+ argv: Sequence[str] | None,
404
+ ) -> tuple[argparse.ArgumentParser, argparse.Namespace]:
405
+ parser = build_parser()
406
+ args = parser.parse_args(argv)
407
+ if args.config is not None:
408
+ args.config = args.config.expanduser().resolve()
409
+ _apply_runtime_configuration(args, parser)
410
+ return parser, args
411
+
412
+
377
413
  def main(argv: Sequence[str] | None = None) -> int:
378
414
  return asyncio.run(async_main(argv))
379
415
 
@@ -11,10 +11,12 @@ from .client import (
11
11
  build_thread_start_params,
12
12
  build_turn_interrupt_params,
13
13
  build_turn_start_params,
14
+ build_turn_steer_params,
14
15
  parse_error_notification,
15
16
  parse_thread_response,
16
17
  parse_turn_notification,
17
18
  parse_turn_response,
19
+ parse_turn_steer_response,
18
20
  )
19
21
  from .events import CodexTurnEventStream
20
22
  from .process import (
@@ -56,8 +58,10 @@ __all__ = [
56
58
  "build_thread_start_params",
57
59
  "build_turn_interrupt_params",
58
60
  "build_turn_start_params",
61
+ "build_turn_steer_params",
59
62
  "parse_error_notification",
60
63
  "parse_thread_response",
61
64
  "parse_turn_notification",
62
65
  "parse_turn_response",
66
+ "parse_turn_steer_response",
63
67
  ]