bazaar-compute-node 0.1.5__tar.gz → 0.1.7__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 (65) hide show
  1. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/PKG-INFO +10 -4
  2. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/README.md +9 -3
  3. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/pyproject.toml +4 -3
  4. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/pyproject.toml.orig +8 -7
  5. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/application.py +63 -26
  6. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/attachments.py +52 -16
  7. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/command.py +19 -0
  8. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/config.py +22 -4
  9. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/registry.py +9 -4
  10. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/transport.py +16 -10
  11. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/windows_pipe.py +4 -2
  12. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/bcc.py +5 -0
  13. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/cli.py +48 -10
  14. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/__init__.py +26 -14
  15. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/approval.py +25 -21
  16. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/client.py +127 -26
  17. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/events.py +9 -9
  18. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/plugin.py +2 -2
  19. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/process.py +23 -0
  20. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/protocol.py +2 -2
  21. {bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/codex_app_server → bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/codex}/runtime.py +160 -37
  22. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/sqlite/codec.py +33 -40
  23. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/sqlite/database.py +16 -4
  24. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/sqlite/migrations.py +26 -0
  25. bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/sqlite/plugin.py +16 -0
  26. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/sqlite/repository.py +18 -91
  27. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/wecom/channel.py +400 -45
  28. bazaar_compute_node-0.1.7/src/bazaar_compute_node/contrib/wecom/outbound.py +193 -0
  29. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/command.py +1 -0
  30. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/instruction.py +3 -0
  31. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/models/__init__.py +2 -0
  32. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/models/entities.py +40 -0
  33. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/orchestration/command.py +130 -18
  34. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/orchestration/services.py +8 -0
  35. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/orchestration/session.py +724 -170
  36. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/orchestration/turn.py +75 -13
  37. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/paths.py +14 -1
  38. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/runtime.py +36 -7
  39. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/storage.py +0 -13
  40. bazaar_compute_node-0.1.7/src/bazaar_compute_node/core/timerwheel.py +316 -0
  41. bazaar_compute_node-0.1.5/src/bazaar_compute_node/contrib/sqlite/plugin.py +0 -11
  42. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/LICENSE +0 -0
  43. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/__init__.py +0 -0
  44. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/__init__.py +0 -0
  45. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/app/wrapper.py +0 -0
  46. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  47. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  48. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  49. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  50. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/sqlite/__init__.py +0 -0
  51. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  52. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  53. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
  54. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/__init__.py +0 -0
  55. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/approval.py +0 -0
  56. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/audit.py +0 -0
  57. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/channel.py +0 -0
  58. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/client.py +0 -0
  59. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/concurrency.py +0 -0
  60. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/correlation.py +0 -0
  61. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  62. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/models/states.py +0 -0
  63. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/observability.py +0 -0
  64. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/orchestration/__init__.py +0 -0
  65. {bazaar_compute_node-0.1.5 → bazaar_compute_node-0.1.7}/src/bazaar_compute_node/core/outcomes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.5
3
+ Version: 0.1.7
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
@@ -24,6 +24,7 @@ Harness 工具。
24
24
  - **自由组合**:根据使用场景分别选择 Channel 与 Runtime,并可独立替换和扩展。
25
25
  - **持续会话**:每个用户和对话拥有独立上下文,节点重启后仍可继续之前的任务。
26
26
  - **真实工作区**:Runtime 可以在独立工作区中分析、创建和修改文件,而不局限于文本回答。
27
+ - **双向附件**:Channel 收到的媒体会进入会话工作区,Runtime 也可以通过 Channel 交付工作区中的文件。
27
28
  - **可靠交付**:自动处理长结果的分批发送并记录交付状态,避免任务结果静默丢失。
28
29
  - **安全边界**:复用所选 Harness 的权限机制,按任务需要控制文件与网络访问。
29
30
  - **跨平台运行**:支持 Linux、macOS 和 Windows,并以后台进程长期运行。
@@ -57,7 +58,6 @@ bcn 将沿着可组合的 Channel、Runtime 与节点通用能力继续扩展。
57
58
 
58
59
  ### 节点能力
59
60
 
60
- - **附件发送**:让 Agent 可以通过当前 Channel 交付生成的文件;
61
61
  - **定时任务**:创建一次性或周期性任务,由节点按计划自动执行并返回结果。
62
62
 
63
63
  ## 安装
@@ -91,7 +91,8 @@ Channel 即可。
91
91
  ### WeCom
92
92
 
93
93
  WeCom Channel 对接企业微信智能机器人,支持单聊、群聊、文本与媒体输入,以及 Markdown
94
- 长消息分批返回。群聊中需要 @机器人,确保消息能够进入节点。
94
+ 长消息分批和附件返回。收到的媒体会以工作区相对路径提供给 Runtime;Runtime 可以把工作区内的
95
+ 普通文件作为附件发送,附件路径不能包含符号链接。群聊中需要 @机器人,确保消息能够进入节点。
95
96
 
96
97
  在 `~/.bcn/config.toml` 中配置 Bot ID:
97
98
 
@@ -128,9 +129,12 @@ Runtime 即可。
128
129
  [runtime]
129
130
  sandbox_mode = "workspace-write"
130
131
  network_access = true
132
+ idle_timeout = 0
131
133
  ```
132
134
 
133
135
  如需覆盖 Codex 的默认模型或推理强度,可以在 `[runtime]` 中增加 `model` 和 `effort`。
136
+ `idle_timeout` 以秒为单位并支持小数;默认值 `0` 与所有 `<= 0` 的值表示 runtime session
137
+ 常驻。设置为正值后,每条新收到的消息都会刷新空闲回收期限。
134
138
  除非你明确需要使用运行用户已有的主机权限,否则不要将 `sandbox_mode` 改为
135
139
  `danger-full-access`。
136
140
 
@@ -167,7 +171,9 @@ uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn restar
167
171
  uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn --help
168
172
  ```
169
173
 
170
- 节点数据默认保存在 `~/.bcn`。停止节点会优雅关闭后台进程,不会删除既有会话、工作区或消息记录。
174
+ 节点数据默认保存在 `~/.bcn`。可通过 `BCN_DATA_NAME` 指定 HOME 下的另一数据目录名称,
175
+ 例如 `BCN_DATA_NAME=.bcn-test`;该值必须是单个目录名。停止节点会优雅关闭后台进程,
176
+ 不会删除既有会话、工作区或消息记录。
171
177
 
172
178
  ## 许可证
173
179
 
@@ -11,6 +11,7 @@ Harness 工具。
11
11
  - **自由组合**:根据使用场景分别选择 Channel 与 Runtime,并可独立替换和扩展。
12
12
  - **持续会话**:每个用户和对话拥有独立上下文,节点重启后仍可继续之前的任务。
13
13
  - **真实工作区**:Runtime 可以在独立工作区中分析、创建和修改文件,而不局限于文本回答。
14
+ - **双向附件**:Channel 收到的媒体会进入会话工作区,Runtime 也可以通过 Channel 交付工作区中的文件。
14
15
  - **可靠交付**:自动处理长结果的分批发送并记录交付状态,避免任务结果静默丢失。
15
16
  - **安全边界**:复用所选 Harness 的权限机制,按任务需要控制文件与网络访问。
16
17
  - **跨平台运行**:支持 Linux、macOS 和 Windows,并以后台进程长期运行。
@@ -44,7 +45,6 @@ bcn 将沿着可组合的 Channel、Runtime 与节点通用能力继续扩展。
44
45
 
45
46
  ### 节点能力
46
47
 
47
- - **附件发送**:让 Agent 可以通过当前 Channel 交付生成的文件;
48
48
  - **定时任务**:创建一次性或周期性任务,由节点按计划自动执行并返回结果。
49
49
 
50
50
  ## 安装
@@ -78,7 +78,8 @@ Channel 即可。
78
78
  ### WeCom
79
79
 
80
80
  WeCom Channel 对接企业微信智能机器人,支持单聊、群聊、文本与媒体输入,以及 Markdown
81
- 长消息分批返回。群聊中需要 @机器人,确保消息能够进入节点。
81
+ 长消息分批和附件返回。收到的媒体会以工作区相对路径提供给 Runtime;Runtime 可以把工作区内的
82
+ 普通文件作为附件发送,附件路径不能包含符号链接。群聊中需要 @机器人,确保消息能够进入节点。
82
83
 
83
84
  在 `~/.bcn/config.toml` 中配置 Bot ID:
84
85
 
@@ -115,9 +116,12 @@ Runtime 即可。
115
116
  [runtime]
116
117
  sandbox_mode = "workspace-write"
117
118
  network_access = true
119
+ idle_timeout = 0
118
120
  ```
119
121
 
120
122
  如需覆盖 Codex 的默认模型或推理强度,可以在 `[runtime]` 中增加 `model` 和 `effort`。
123
+ `idle_timeout` 以秒为单位并支持小数;默认值 `0` 与所有 `<= 0` 的值表示 runtime session
124
+ 常驻。设置为正值后,每条新收到的消息都会刷新空闲回收期限。
121
125
  除非你明确需要使用运行用户已有的主机权限,否则不要将 `sandbox_mode` 改为
122
126
  `danger-full-access`。
123
127
 
@@ -154,7 +158,9 @@ uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn restar
154
158
  uvx --from 'git+https://github.com/yuchanns/bazaar-compute-node@main' bcn --help
155
159
  ```
156
160
 
157
- 节点数据默认保存在 `~/.bcn`。停止节点会优雅关闭后台进程,不会删除既有会话、工作区或消息记录。
161
+ 节点数据默认保存在 `~/.bcn`。可通过 `BCN_DATA_NAME` 指定 HOME 下的另一数据目录名称,
162
+ 例如 `BCN_DATA_NAME=.bcn-test`;该值必须是单个目录名。停止节点会优雅关闭后台进程,
163
+ 不会删除既有会话、工作区或消息记录。
158
164
 
159
165
  ## 许可证
160
166
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.5"
3
+ version = "0.1.7"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -19,7 +19,7 @@ Repository = "https://github.com/yuchanns/bazaar-compute-node"
19
19
  bcn = "bazaar_compute_node.cli:main"
20
20
 
21
21
  [project.entry-points."bazaar_compute_node.runtimes"]
22
- codex = "bazaar_compute_node.contrib.codex_app_server.plugin:create_runtime"
22
+ codex = "bazaar_compute_node.contrib.codex.plugin:create_runtime"
23
23
 
24
24
  [project.entry-points."bazaar_compute_node.channels"]
25
25
  wecom = "bazaar_compute_node.contrib.wecom.plugin:create_channel"
@@ -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.5"
3
+ version = "0.1.7"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -19,7 +19,7 @@ Repository = "https://github.com/yuchanns/bazaar-compute-node"
19
19
  bcn = "bazaar_compute_node.cli:main"
20
20
 
21
21
  [project.entry-points."bazaar_compute_node.runtimes"]
22
- codex = "bazaar_compute_node.contrib.codex_app_server.plugin:create_runtime"
22
+ codex = "bazaar_compute_node.contrib.codex.plugin:create_runtime"
23
23
 
24
24
  [project.entry-points."bazaar_compute_node.channels"]
25
25
  wecom = "bazaar_compute_node.contrib.wecom.plugin:create_channel"
@@ -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"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import hmac
5
+ import math
5
6
  import os
6
7
  import re
7
8
  import secrets
@@ -17,6 +18,7 @@ from ..core.orchestration import SessionOrchestrator
17
18
  from ..core.paths import resolve_data_dir, resolve_workspace_dir
18
19
  from ..core.runtime import IRuntime, RuntimeCommandContext, RuntimeSandboxMode
19
20
  from ..core.storage import IStorage, NodeIdentity
21
+ from ..core.timerwheel import TimerWheel
20
22
  from .attachments import AttachmentMaterializer
21
23
  from .command import (
22
24
  CommandDispatcher,
@@ -64,6 +66,7 @@ class NodeApplication:
64
66
  runtime_options: Mapping[str, str] | None = None,
65
67
  runtime_sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE,
66
68
  runtime_network_access: bool = True,
69
+ runtime_idle_timeout_seconds: float = 0,
67
70
  channel_options: Mapping[str, object] | None = None,
68
71
  runtime_environment_include: Sequence[str] = (),
69
72
  timeout_budget: TimeoutBudget | None = None,
@@ -81,8 +84,7 @@ class NodeApplication:
81
84
  self.command_log: list[CommandRecord] = []
82
85
  self._wrapper_path: Path | None = None
83
86
  self._identity: NodeIdentity | None = None
84
- self._session_capabilities: dict[str, str] = {}
85
- self._runtime_session_ids: dict[str, str] = {}
87
+ self._session_capabilities: dict[str, tuple[str, str]] = {}
86
88
  self._started = False
87
89
  self._stopped = asyncio.Event()
88
90
  self._runtime_environment_include = tuple(runtime_environment_include)
@@ -106,6 +108,23 @@ class NodeApplication:
106
108
  startup_timeout_seconds=self.timeout_budget.startup_seconds,
107
109
  )
108
110
  self.runtime: IRuntime = factories.runtime(self._runtime_context)
111
+ if (
112
+ isinstance(runtime_idle_timeout_seconds, bool)
113
+ or not isinstance(runtime_idle_timeout_seconds, int | float)
114
+ or not math.isfinite(runtime_idle_timeout_seconds)
115
+ ):
116
+ raise ValueError("runtime_idle_timeout_seconds must be a finite number")
117
+ self.timer_wheel = TimerWheel()
118
+ if (
119
+ runtime_idle_timeout_seconds > 0
120
+ and runtime_idle_timeout_seconds * 1_000 > self.timer_wheel.maximum_delay_ms
121
+ ):
122
+ raise ValueError("runtime_idle_timeout_seconds exceeds the timer horizon")
123
+ runtime_idle_timeout_ms = (
124
+ math.ceil(runtime_idle_timeout_seconds * 1_000)
125
+ if runtime_idle_timeout_seconds > 0
126
+ else 0
127
+ )
109
128
  self.orchestrator = SessionOrchestrator(
110
129
  node_id=node_id,
111
130
  workspace_id=workspace_id,
@@ -114,6 +133,9 @@ class NodeApplication:
114
133
  storage=self.storage,
115
134
  audit=self.audit,
116
135
  timeout_budget=self.timeout_budget,
136
+ timer_wheel=self.timer_wheel,
137
+ runtime_idle_timeout_ms=runtime_idle_timeout_ms,
138
+ workspace=self._workspace_path,
117
139
  on_node_initialized=self._ensure_workspace,
118
140
  )
119
141
  self.command_service = self.orchestrator.command_service
@@ -139,17 +161,23 @@ class NodeApplication:
139
161
  async def start(self) -> None:
140
162
  if self._started:
141
163
  return
142
- self.data_dir.mkdir(parents=True, exist_ok=True)
164
+ await asyncio.to_thread(self.data_dir.mkdir, parents=True, exist_ok=True)
143
165
  if os.name != "nt":
144
- self.data_dir.chmod(0o700)
145
- self._wrapper_path = install_bcc_wrapper(self.data_dir / "bin")
166
+ await asyncio.to_thread(self.data_dir.chmod, 0o700)
167
+ self._wrapper_path = await asyncio.to_thread(
168
+ install_bcc_wrapper, self.data_dir / "bin"
169
+ )
146
170
  try:
171
+ await self.timer_wheel.start()
147
172
  await self.command_server.start()
148
173
  await self.orchestrator.start(
149
174
  timeout=self.timeout_budget.startup_seconds,
150
175
  )
151
176
  except BaseException:
152
- await self.stop()
177
+ try:
178
+ await self.stop()
179
+ finally:
180
+ await self.timer_wheel.close()
153
181
  raise
154
182
  self._started = True
155
183
  self._stopped.clear()
@@ -165,7 +193,10 @@ class NodeApplication:
165
193
  try:
166
194
  await self.command_server.stop()
167
195
  finally:
168
- self._cleanup_bcc_wrapper()
196
+ try:
197
+ await self._cleanup_bcc_wrapper()
198
+ finally:
199
+ await self.timer_wheel.close()
169
200
  return
170
201
  self._started = False
171
202
  self.command_dispatcher.stop_accepting()
@@ -183,15 +214,18 @@ class NodeApplication:
183
214
  await self.command_server.stop()
184
215
  finally:
185
216
  try:
186
- self._cleanup_bcc_wrapper()
217
+ await self._cleanup_bcc_wrapper()
187
218
  finally:
188
- self._stopped.set()
219
+ try:
220
+ await self.timer_wheel.close()
221
+ finally:
222
+ self._stopped.set()
189
223
 
190
- def _cleanup_bcc_wrapper(self) -> None:
224
+ async def _cleanup_bcc_wrapper(self) -> None:
191
225
  wrapper_path = self._wrapper_path
192
226
  if wrapper_path is None:
193
227
  return
194
- remove_bcc_wrapper(wrapper_path)
228
+ await asyncio.to_thread(remove_bcc_wrapper, wrapper_path)
195
229
  self._wrapper_path = None
196
230
 
197
231
  async def wait(self) -> None:
@@ -275,18 +309,19 @@ class NodeApplication:
275
309
  "SESSION_NOT_FOUND",
276
310
  f"unknown bcn session: {session_id}",
277
311
  )
278
- runtime_session = await transaction.find_runtime_session(session_id)
279
- if runtime_session is None or runtime_session_id != (runtime_session.id):
312
+ runtime_session = self.orchestrator.runtime_session(session_id)
313
+ if runtime_session is None or runtime_session_id != runtime_session.id:
280
314
  raise CommandDispatchError(
281
315
  "SESSION_BINDING_FAILED",
282
316
  "runtime session binding is invalid",
283
317
  )
284
- expected_capability = self._session_capabilities.get(session_id)
318
+ capability_binding = self._session_capabilities.get(session_id)
285
319
  if (
286
- expected_capability is None
320
+ capability_binding is None
321
+ or capability_binding[0] != runtime_session.id
287
322
  or not isinstance(session_capability, str)
288
323
  or not hmac.compare_digest(
289
- session_capability.encode(), expected_capability.encode()
324
+ session_capability.encode(), capability_binding[1].encode()
290
325
  )
291
326
  ):
292
327
  raise CommandDispatchError(
@@ -295,7 +330,9 @@ class NodeApplication:
295
330
  )
296
331
 
297
332
  def _runtime_environment(self, session: RuntimeSession) -> Mapping[str, str]:
298
- self._runtime_session_ids[session.bcn_session_id] = session.id
333
+ runtime_session = self.orchestrator.runtime_session(session.bcn_session_id)
334
+ if runtime_session is None or runtime_session.id != session.id:
335
+ raise RuntimeError("runtime session is not the current live binding")
299
336
  return self._build_command_environment(
300
337
  session.bcn_session_id,
301
338
  session.id,
@@ -313,10 +350,11 @@ class NodeApplication:
313
350
  wrapper_path = self._wrapper_path
314
351
  if wrapper_path is None:
315
352
  raise RuntimeError("bcc wrapper is not installed")
316
- session_capability = self._session_capabilities.setdefault(
317
- session_id,
318
- secrets.token_urlsafe(32),
319
- )
353
+ capability_binding = self._session_capabilities.get(session_id)
354
+ if capability_binding is None or capability_binding[0] != runtime_session_id:
355
+ capability_binding = (runtime_session_id, secrets.token_urlsafe(32))
356
+ self._session_capabilities[session_id] = capability_binding
357
+ session_capability = capability_binding[1]
320
358
  wrapper_directory = str(wrapper_path.parent)
321
359
  allowed = set(_PLATFORM_ENVIRONMENT)
322
360
  for name in self.runtime.environment_variable_names():
@@ -365,13 +403,12 @@ class NodeApplication:
365
403
  if wrapper_path is None:
366
404
  raise RuntimeError("bcc wrapper is not installed")
367
405
  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
- )
406
+ runtime_session = self.orchestrator.runtime_session(session_id)
407
+ if runtime_session is None:
408
+ raise RuntimeError("runtime session is not live")
372
409
  environment = self._build_command_environment(
373
410
  session_id,
374
- runtime_session_id,
411
+ runtime_session.id,
375
412
  )
376
413
  process = await asyncio.create_subprocess_exec(
377
414
  str(wrapper_path),
@@ -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,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+ import os
3
5
  import tomllib
4
6
  from dataclasses import dataclass
5
7
  from pathlib import Path
@@ -19,10 +21,12 @@ class NodeConfiguration:
19
21
  storage: str | None = None
20
22
  audit: str | None = None
21
23
  endpoint: str | None = None
24
+ database_name: str | None = None
22
25
  model: str | None = None
23
26
  effort: str | None = None
24
27
  sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE
25
28
  network_access: bool = True
29
+ runtime_idle_timeout_seconds: float = 0
26
30
  runtime_env_include: tuple[str, ...] = ()
27
31
  wecom_bot_id: str | None = None
28
32
  wecom_websocket_url: str | None = None
@@ -36,13 +40,18 @@ def resolve_config_path() -> Path:
36
40
  return resolve_data_dir() / CONFIG_FILENAME
37
41
 
38
42
 
39
- def load_node_configuration() -> NodeConfiguration:
40
- path = resolve_config_path()
43
+ def load_node_configuration(path: Path | None = None) -> NodeConfiguration:
44
+ path = path or resolve_config_path()
41
45
  try:
46
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
47
+ try:
48
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
49
+ except FileExistsError:
50
+ pass
51
+ else:
52
+ os.close(descriptor)
42
53
  with path.open("rb") as config_file:
43
54
  payload = tomllib.load(config_file)
44
- except FileNotFoundError:
45
- return NodeConfiguration()
46
55
  except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
47
56
  raise ConfigurationError(f"cannot read {path}: {error}") from error
48
57
 
@@ -74,16 +83,25 @@ def load_node_configuration() -> NodeConfiguration:
74
83
  network_access = runtime.get("network_access", True)
75
84
  if not isinstance(network_access, bool):
76
85
  raise ConfigurationError("runtime.network_access must be a boolean")
86
+ idle_timeout = runtime.get("idle_timeout", 0)
87
+ if (
88
+ isinstance(idle_timeout, bool)
89
+ or not isinstance(idle_timeout, int | float)
90
+ or not math.isfinite(idle_timeout)
91
+ ):
92
+ raise ConfigurationError("runtime.idle_timeout must be a finite number")
77
93
  return NodeConfiguration(
78
94
  channel=_optional_text(node.get("channel"), "node.channel"),
79
95
  runtime=_optional_text(node.get("runtime"), "node.runtime"),
80
96
  storage=_optional_text(node.get("storage"), "node.storage"),
81
97
  audit=_optional_text(node.get("audit"), "node.audit"),
82
98
  endpoint=_optional_text(node.get("endpoint"), "node.endpoint"),
99
+ database_name=_optional_text(node.get("database_name"), "node.database_name"),
83
100
  model=_optional_text(runtime.get("model"), "runtime.model"),
84
101
  effort=_optional_text(runtime.get("effort"), "runtime.effort"),
85
102
  sandbox_mode=parsed_sandbox_mode,
86
103
  network_access=network_access,
104
+ runtime_idle_timeout_seconds=float(idle_timeout),
87
105
  runtime_env_include=_text_list(
88
106
  runtime_env.get("include", []), "runtime.env.include"
89
107
  ),
@@ -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),