bazaar-compute-node 0.1.22__tar.gz → 0.1.23__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 (116) hide show
  1. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/PKG-INFO +2 -1
  2. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/pyproject.toml +2 -1
  3. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/pyproject.toml.orig +2 -1
  4. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/agent.py +24 -2
  5. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/command.py +199 -78
  6. bazaar_compute_node-0.1.23/src/bazaar_compute_node/app/resource_dispatch.py +630 -0
  7. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/system_service.py +2 -0
  8. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/bcc.py +373 -241
  9. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/__init__.py +1 -1
  10. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/agent_migration.py +2 -22
  11. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/codec.py +26 -88
  12. bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/handoff_codec.py +24 -0
  13. bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/handoff_migration.py +29 -0
  14. bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/handoff_repository.py +166 -0
  15. bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/inbox_migration.py +19 -0
  16. bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/migration.py +19 -0
  17. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/migrations.py +19 -15
  18. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/plugin.py +1 -1
  19. bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/reminder_codec.py +50 -0
  20. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/reminder_migration.py +2 -22
  21. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/reminder_repository.py +3 -25
  22. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/repository.py +18 -27
  23. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/scoped_repository.py +188 -41
  24. bazaar_compute_node-0.1.22/src/bazaar_compute_node/contrib/sqlite/reminder_database.py → bazaar_compute_node-0.1.23/src/bazaar_compute_node/contrib/sqlite/storage.py +12 -12
  25. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/audit.py +0 -12
  26. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/command.py +71 -7
  27. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/correlation.py +0 -6
  28. bazaar_compute_node-0.1.23/src/bazaar_compute_node/core/handoff.py +49 -0
  29. bazaar_compute_node-0.1.23/src/bazaar_compute_node/core/inbox.py +30 -0
  30. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/instruction.py +36 -13
  31. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/models/__init__.py +4 -0
  32. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/models/entities.py +39 -58
  33. bazaar_compute_node-0.1.23/src/bazaar_compute_node/core/models/handoff.py +26 -0
  34. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/models/reminder.py +13 -37
  35. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/models/states.py +0 -2
  36. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/command.py +101 -45
  37. bazaar_compute_node-0.1.23/src/bazaar_compute_node/core/orchestration/handoff_command.py +301 -0
  38. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/session.py +84 -8
  39. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/turn.py +8 -0
  40. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/reminder.py +5 -24
  41. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/reminder_schedule.py +0 -18
  42. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/storage.py +48 -0
  43. bazaar_compute_node-0.1.22/src/bazaar_compute_node/app/reminder_dispatch.py +0 -411
  44. bazaar_compute_node-0.1.22/src/bazaar_compute_node/contrib/sqlite/reminder_codec.py +0 -63
  45. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/LICENSE +0 -0
  46. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/README.md +0 -0
  47. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/__init__.py +0 -0
  48. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/__init__.py +0 -0
  49. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/agent_management.py +0 -0
  50. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/application.py +0 -0
  51. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/attachments.py +0 -0
  52. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/config.py +0 -0
  53. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/registry.py +0 -0
  54. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/transport.py +0 -0
  55. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/windows_pipe.py +0 -0
  56. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/app/wrapper.py +0 -0
  57. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/cli.py +0 -0
  58. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  59. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/__init__.py +0 -0
  60. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/approval.py +0 -0
  61. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/client.py +0 -0
  62. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/events.py +0 -0
  63. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/plugin.py +0 -0
  64. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/process.py +0 -0
  65. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/protocol.py +0 -0
  66. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/codex/runtime.py +0 -0
  67. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/__init__.py +0 -0
  68. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/api.py +0 -0
  69. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/approval.py +0 -0
  70. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/attachments.py +0 -0
  71. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/channel.py +0 -0
  72. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/frame.py +0 -0
  73. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/identity.py +0 -0
  74. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/outbound.py +0 -0
  75. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/plugin.py +0 -0
  76. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/lark/transport.py +0 -0
  77. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  78. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  79. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  80. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/sqlite/database.py +0 -0
  81. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/__init__.py +0 -0
  82. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/api.py +0 -0
  83. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/approval.py +0 -0
  84. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/attachments.py +0 -0
  85. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/channel.py +0 -0
  86. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/identity.py +0 -0
  87. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/markdown.py +0 -0
  88. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/outbound.py +0 -0
  89. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/telegram/plugin.py +0 -0
  90. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  91. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/wecom/channel.py +0 -0
  92. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  93. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/wecom/outbound.py +0 -0
  94. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
  95. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/__init__.py +0 -0
  96. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/approval.py +0 -0
  97. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/channel.py +0 -0
  98. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/client.py +0 -0
  99. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/concurrency.py +0 -0
  100. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  101. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/models/reminder_owner.py +0 -0
  102. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/observability.py +0 -0
  103. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/__init__.py +0 -0
  104. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/delivery.py +0 -0
  105. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/error_feedback.py +0 -0
  106. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/reminder.py +0 -0
  107. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/reminder_command.py +0 -0
  108. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/orchestration/services.py +0 -0
  109. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/outcomes.py +0 -0
  110. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/paths.py +0 -0
  111. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/runtime.py +0 -0
  112. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/core/timerwheel.py +0 -0
  113. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/i18n/__init__.py +0 -0
  114. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/i18n/catalog.py +0 -0
  115. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/i18n/english.py +0 -0
  116. {bazaar_compute_node-0.1.22 → bazaar_compute_node-0.1.23}/src/bazaar_compute_node/i18n/schinese.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.22
3
+ Version: 0.1.23
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
7
7
  Requires-Dist: aiohttp>=3.12.0
8
8
  Requires-Dist: aiosqlite>=0.20.0
9
9
  Requires-Dist: cryptography>=45.0.0
10
+ Requires-Dist: pydantic>=2.0.0
10
11
  Requires-Dist: protobug==1.0.0
11
12
  Requires-Dist: tzdata>=2024.1 ; sys_platform == 'win32'
12
13
  Requires-Python: >=3.14
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.22"
3
+ version = "0.1.23"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "aiohttp>=3.12.0",
11
11
  "aiosqlite>=0.20.0",
12
12
  "cryptography>=45.0.0",
13
+ "pydantic>=2.0.0",
13
14
  "protobug==1.0.0",
14
15
  "tzdata>=2024.1; sys_platform == 'win32'",
15
16
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.22"
3
+ version = "0.1.23"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "aiohttp>=3.12.0",
11
11
  "aiosqlite>=0.20.0",
12
12
  "cryptography>=45.0.0",
13
+ "pydantic>=2.0.0",
13
14
  "protobug==1.0.0",
14
15
  "tzdata>=2024.1; sys_platform == 'win32'",
15
16
  ]
@@ -10,6 +10,8 @@ import secrets
10
10
  from collections.abc import Callable, Mapping, Sequence
11
11
  from dataclasses import dataclass
12
12
  from pathlib import Path
13
+ from time import time_ns
14
+ from typing import cast
13
15
 
14
16
  from ..core.channel import AgentScopedChannel, ChannelContext, IChannel
15
17
  from ..core.concurrency import ISessionConcurrency, SessionLockRegistry
@@ -17,17 +19,19 @@ from ..core.lifecycle import TimeoutBudget
17
19
  from ..core.models import RuntimeSession
18
20
  from ..core.observability import IAudit
19
21
  from ..core.orchestration import SessionOrchestrator
22
+ from ..core.orchestration.handoff_command import HandoffCommandService
20
23
  from ..core.orchestration.reminder_command import ReminderCommandService
24
+ from ..core.orchestration.services import SessionAuditRecorder
21
25
  from ..core.paths import resolve_workspace_dir
22
26
  from ..core.runtime import IRuntime, RuntimeCommandContext
23
- from ..core.storage import IStorageScope
27
+ from ..core.storage import IHandoffStorageScope, IStorageScope
24
28
  from ..core.timerwheel import TimerWheel
25
29
  from ..i18n import Translator
26
30
  from .attachments import AttachmentMaterializer
27
31
  from .command import CommandDispatchError
28
32
  from .config import AgentConfiguration
29
33
  from .registry import AgentAdapterFactories
30
- from .reminder_dispatch import CommandDispatcher
34
+ from .resource_dispatch import CommandDispatcher
31
35
  from .wrapper import install_bcc_wrapper, remove_bcc_wrapper
32
36
 
33
37
  CommandRecord = tuple[str, tuple[str, ...]]
@@ -78,6 +82,7 @@ class AgentApplication:
78
82
  self.agent_id = configuration.id
79
83
  self.name = configuration.name
80
84
  self.storage = storage
85
+ self._handoff_storage = cast(IHandoffStorageScope, storage)
81
86
  self.audit = audit
82
87
  self.timer_wheel = timer_wheel
83
88
  self.timeout_budget = timeout_budget
@@ -147,6 +152,7 @@ class AgentApplication:
147
152
  channel=self.channel,
148
153
  runtime=self.runtime,
149
154
  storage=self.storage,
155
+ handoff_storage=self._handoff_storage,
150
156
  audit=self.audit,
151
157
  timeout_budget=self.timeout_budget,
152
158
  timer_wheel=self.timer_wheel,
@@ -161,6 +167,16 @@ class AgentApplication:
161
167
  concurrency=reminder_concurrency,
162
168
  poke=reminder_poke,
163
169
  )
170
+ self.handoff_service = HandoffCommandService(
171
+ storage=self._handoff_storage,
172
+ audit=SessionAuditRecorder(
173
+ sink=self.audit,
174
+ timeout_budget=self.timeout_budget,
175
+ clock=lambda: time_ns() // 1_000_000,
176
+ ),
177
+ publish_wake=self.publish_handoff_wake,
178
+ node_id=lambda: self.agent_id,
179
+ )
164
180
  self._provider_control_handler = (
165
181
  factories.control(self._adapter_context())
166
182
  if factories.control is not None
@@ -169,6 +185,7 @@ class AgentApplication:
169
185
  self.command_dispatcher = CommandDispatcher(
170
186
  self.orchestrator.command_service,
171
187
  reminder_service=self.reminder_service,
188
+ handoff_service=self.handoff_service,
172
189
  timeout_budget=self.timeout_budget,
173
190
  control_handler=(
174
191
  self._handle_control
@@ -270,6 +287,11 @@ class AgentApplication:
270
287
  raise RuntimeError("Agent application is not started")
271
288
  await self.orchestrator.publish_reminder_wake(session_id)
272
289
 
290
+ async def publish_handoff_wake(self, session_id: str) -> None:
291
+ if not self._started:
292
+ raise RuntimeError("Agent application is not started")
293
+ await self.orchestrator.publish_handoff_wake(session_id)
294
+
273
295
  def health_record(self) -> dict[str, object]:
274
296
  return {
275
297
  "agent_id": self.agent_id,
@@ -4,13 +4,18 @@ import asyncio
4
4
  from collections.abc import Awaitable, Callable, Mapping
5
5
  from datetime import UTC, datetime
6
6
  from time import time_ns
7
+ from typing import Annotated, Literal, cast
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, ValidationError
7
10
 
8
11
  from ..core.command import (
9
12
  ICommandService,
13
+ InboxListResult,
14
+ MessageSendHandoffRequired,
10
15
  SessionNotFoundError,
11
16
  )
12
17
  from ..core.lifecycle import TimeoutBudget
13
- from ..core.models import InboundMessage, OutboundMessage
18
+ from ..core.models import InboundMessage, InboxTargetSummary, OutboundMessage
14
19
 
15
20
 
16
21
  def serialize_inbound(message: InboundMessage) -> dict[str, object]:
@@ -84,6 +89,38 @@ def serialize_outbound(message: OutboundMessage) -> dict[str, object]:
84
89
  }
85
90
 
86
91
 
92
+ def serialize_inbox_target(summary: InboxTargetSummary) -> dict[str, object]:
93
+ sender = summary.latest_sender
94
+ latest_time_ms = (
95
+ summary.latest_provider_time_ms
96
+ if summary.latest_provider_time_ms is not None
97
+ else summary.latest_received_at_ms
98
+ )
99
+ return {
100
+ "target": summary.target,
101
+ "session_id": summary.session_id,
102
+ "target_kind": summary.target_kind.value,
103
+ "current": summary.current,
104
+ "pending_count": summary.pending_count,
105
+ "last_activity_at_ms": summary.last_activity_at_ms,
106
+ "latest_message_id": summary.latest_message_id,
107
+ "latest_sender": (
108
+ None if sender is None else {"id": sender.id, "name": sender.name}
109
+ ),
110
+ "latest_time_ms": latest_time_ms,
111
+ }
112
+
113
+
114
+ def serialize_inbox_list(result: InboxListResult) -> dict[str, object]:
115
+ return {
116
+ "targets": [serialize_inbox_target(target) for target in result.targets],
117
+ "total": result.total,
118
+ "shown": result.shown,
119
+ "offset": result.offset,
120
+ "has_more": result.has_more,
121
+ }
122
+
123
+
87
124
  class CommandDispatchError(ValueError):
88
125
  def __init__(
89
126
  self,
@@ -100,6 +137,125 @@ class CommandDispatchError(ValueError):
100
137
  self.next_action = next_action
101
138
 
102
139
 
140
+ NonEmptyText = Annotated[StrictStr, Field(min_length=1)]
141
+ PositiveInt = Annotated[StrictInt, Field(gt=0)]
142
+ NonNegativeInt = Annotated[StrictInt, Field(ge=0)]
143
+
144
+
145
+ class _CommandRequest(BaseModel):
146
+ model_config = ConfigDict(extra="ignore", strict=True)
147
+
148
+ kind: Literal["command"]
149
+ resource: StrictStr
150
+ command: StrictStr
151
+ session_id: NonEmptyText
152
+
153
+
154
+ class _MessageCheckRequest(_CommandRequest):
155
+ resource: Literal["message"]
156
+ command: Literal["check"]
157
+
158
+
159
+ class _MessageReadRequest(_CommandRequest):
160
+ resource: Literal["message"]
161
+ command: Literal["read"]
162
+ target: NonEmptyText
163
+ around_message_id: StrictStr | None = None
164
+ limit: PositiveInt = 100
165
+
166
+
167
+ class _MessageSendRequest(_CommandRequest):
168
+ resource: Literal["message"]
169
+ command: Literal["send"]
170
+ target: NonEmptyText
171
+ body: StrictStr
172
+ command_id: NonEmptyText
173
+ attachment_paths: list[NonEmptyText] = Field(default_factory=list)
174
+ reply_to_message_id: NonEmptyText | None = None
175
+ created_at_ms: NonNegativeInt = Field(
176
+ default_factory=lambda: time_ns() // 1_000_000
177
+ )
178
+
179
+
180
+ class _InboxListRequest(_CommandRequest):
181
+ resource: Literal["inbox"]
182
+ command: Literal["list"]
183
+ limit: PositiveInt = 100
184
+ offset: NonNegativeInt = 0
185
+
186
+
187
+ class _ThreadUnfollowRequest(_CommandRequest):
188
+ resource: Literal["thread"]
189
+ command: Literal["unfollow"]
190
+ target: NonEmptyText
191
+
192
+
193
+ _RequestModel = type[_CommandRequest]
194
+
195
+ _REQUEST_MODELS: dict[tuple[str, str], _RequestModel] = {
196
+ ("message", "check"): _MessageCheckRequest,
197
+ ("message", "read"): _MessageReadRequest,
198
+ ("message", "send"): _MessageSendRequest,
199
+ ("inbox", "list"): _InboxListRequest,
200
+ ("thread", "unfollow"): _ThreadUnfollowRequest,
201
+ }
202
+
203
+ _REQUEST_ERRORS: dict[str, tuple[str, str]] = {
204
+ "session_id": ("SESSION_REQUIRED", "session_id must be a non-empty string"),
205
+ "limit": ("INVALID_LIMIT", "limit must be a positive integer"),
206
+ "offset": ("INVALID_OFFSET", "offset must be a non-negative integer"),
207
+ "target": ("TARGET_REQUIRED", "target must be a non-empty string"),
208
+ "around_message_id": (
209
+ "INVALID_AROUND_MESSAGE",
210
+ "around_message_id must be a string",
211
+ ),
212
+ "body": ("BODY_REQUIRED", "body must be text"),
213
+ "command_id": (
214
+ "COMMAND_ID_REQUIRED",
215
+ "command_id must be a non-empty string",
216
+ ),
217
+ "attachment_paths": (
218
+ "INVALID_ATTACHMENTS",
219
+ "attachment_paths must be a list of non-empty strings",
220
+ ),
221
+ "reply_to_message_id": (
222
+ "INVALID_REPLY_TO",
223
+ "reply_to_message_id must be a non-empty string",
224
+ ),
225
+ "created_at_ms": (
226
+ "INVALID_CREATED_AT",
227
+ "created_at_ms must be non-negative",
228
+ ),
229
+ }
230
+
231
+
232
+ def _parse_command_request[RequestT: _CommandRequest](
233
+ request: Mapping[str, object],
234
+ model: type[RequestT],
235
+ *,
236
+ errors: Mapping[str, tuple[str, str]] | None = None,
237
+ ) -> RequestT:
238
+ try:
239
+ return model.model_validate(request)
240
+ except ValidationError as error:
241
+ field_name = next(
242
+ (
243
+ part
244
+ for part in error.errors()[0].get("loc", ())
245
+ if isinstance(part, str)
246
+ ),
247
+ "request",
248
+ )
249
+ error_map = dict(_REQUEST_ERRORS)
250
+ if errors is not None:
251
+ error_map.update(errors)
252
+ code, message = error_map.get(
253
+ field_name,
254
+ ("INVALID_COMMAND", "command request is invalid"),
255
+ )
256
+ raise CommandDispatchError(code, message) from error
257
+
258
+
103
259
  ControlHandler = Callable[[Mapping[str, object]], Awaitable[Mapping[str, object]]]
104
260
  SessionBindingValidator = Callable[[str, Mapping[str, object]], Awaitable[None]]
105
261
 
@@ -225,14 +381,14 @@ class CommandDispatcher:
225
381
  self._drained.set()
226
382
 
227
383
  async def _dispatch_command(
228
- self, request: Mapping[str, object]
384
+ self, raw_request: Mapping[str, object]
229
385
  ) -> Mapping[str, object]:
230
- resource = request.get("resource")
386
+ resource = raw_request.get("resource")
231
387
  if not isinstance(resource, str) or not resource:
232
388
  raise CommandDispatchError(
233
389
  "RESOURCE_REQUIRED", "resource must be a non-empty string"
234
390
  )
235
- command = request.get("command")
391
+ command = raw_request.get("command")
236
392
  if not isinstance(command, str) or not command:
237
393
  raise CommandDispatchError(
238
394
  "COMMAND_REQUIRED", "command must be a non-empty string"
@@ -242,6 +398,11 @@ class CommandDispatcher:
242
398
  raise CommandDispatchError(
243
399
  "UNKNOWN_COMMAND", f"unsupported message command: {command}"
244
400
  )
401
+ elif resource == "inbox":
402
+ if command != "list":
403
+ raise CommandDispatchError(
404
+ "UNKNOWN_COMMAND", f"unsupported inbox command: {command}"
405
+ )
245
406
  elif resource == "thread":
246
407
  if command != "unfollow":
247
408
  raise CommandDispatchError(
@@ -252,13 +413,12 @@ class CommandDispatcher:
252
413
  "UNKNOWN_RESOURCE", f"unsupported command resource: {resource}"
253
414
  )
254
415
 
255
- session_id = request.get("session_id")
256
- if not isinstance(session_id, str) or not session_id:
257
- raise CommandDispatchError(
258
- "SESSION_REQUIRED", "session_id must be a non-empty string"
259
- )
416
+ request = _parse_command_request(
417
+ raw_request, _REQUEST_MODELS[(resource, command)]
418
+ )
419
+ session_id = request.session_id
260
420
  if self._session_binding_validator is not None:
261
- await self._session_binding_validator(session_id, request)
421
+ await self._session_binding_validator(session_id, raw_request)
262
422
 
263
423
  if resource == "message" and command == "check":
264
424
  result = await self._service.check(session_id)
@@ -277,27 +437,22 @@ class CommandDispatcher:
277
437
  },
278
438
  }
279
439
 
440
+ if resource == "inbox" and command == "list":
441
+ request = cast(_InboxListRequest, request)
442
+ result = await self._service.list_inbox(
443
+ session_id,
444
+ limit=request.limit,
445
+ offset=request.offset,
446
+ )
447
+ return {"ok": True, "result": serialize_inbox_list(result)}
448
+
280
449
  if resource == "message" and command == "read":
281
- target = request.get("target")
282
- if not isinstance(target, str) or not target:
283
- raise CommandDispatchError(
284
- "TARGET_REQUIRED", "target must be a non-empty string"
285
- )
286
- around_message_id = request.get("around_message_id")
287
- if around_message_id is not None and not isinstance(around_message_id, str):
288
- raise CommandDispatchError(
289
- "INVALID_AROUND_MESSAGE", "around_message_id must be a string"
290
- )
291
- limit = request.get("limit", 100)
292
- if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
293
- raise CommandDispatchError(
294
- "INVALID_LIMIT", "limit must be a positive integer"
295
- )
450
+ request = cast(_MessageReadRequest, request)
296
451
  result = await self._service.read(
297
452
  session_id,
298
- target=target,
299
- around_message_id=around_message_id,
300
- limit=limit,
453
+ target=request.target,
454
+ around_message_id=request.around_message_id,
455
+ limit=request.limit,
301
456
  )
302
457
  return {
303
458
  "ok": True,
@@ -316,65 +471,31 @@ class CommandDispatcher:
316
471
  }
317
472
 
318
473
  if resource == "message" and command == "send":
319
- target = request.get("target")
320
- body = request.get("body")
321
- command_id = request.get("command_id")
322
- attachment_paths = request.get("attachment_paths", [])
323
- reply_to_message_id = request.get("reply_to_message_id")
324
- created_at_ms = request.get("created_at_ms", time_ns() // 1_000_000)
325
- if not isinstance(target, str) or not target:
326
- raise CommandDispatchError(
327
- "TARGET_REQUIRED", "target must be a non-empty string"
328
- )
329
- if not isinstance(body, str):
330
- raise CommandDispatchError("BODY_REQUIRED", "body must be text")
331
- if not isinstance(command_id, str) or not command_id:
332
- raise CommandDispatchError(
333
- "COMMAND_ID_REQUIRED", "command_id must be a non-empty string"
334
- )
335
- if not isinstance(attachment_paths, list) or not all(
336
- isinstance(path, str) and path for path in attachment_paths
337
- ):
338
- raise CommandDispatchError(
339
- "INVALID_ATTACHMENTS",
340
- "attachment_paths must be a list of non-empty strings",
341
- )
342
- if reply_to_message_id is not None and (
343
- not isinstance(reply_to_message_id, str) or not reply_to_message_id
344
- ):
345
- raise CommandDispatchError(
346
- "INVALID_REPLY_TO",
347
- "reply_to_message_id must be a non-empty string",
348
- )
349
- if (
350
- isinstance(created_at_ms, bool)
351
- or not isinstance(created_at_ms, int)
352
- or created_at_ms < 0
353
- ):
354
- raise CommandDispatchError(
355
- "INVALID_CREATED_AT", "created_at_ms must be non-negative"
356
- )
474
+ request = cast(_MessageSendRequest, request)
357
475
  result = await self._service.send(
358
476
  session_id=session_id,
359
- command_id=command_id,
360
- target=target,
361
- body=body,
362
- created_at_ms=created_at_ms,
363
- attachment_paths=tuple(attachment_paths),
364
- reply_to_message_id=reply_to_message_id,
477
+ command_id=request.command_id,
478
+ target=request.target,
479
+ body=request.body,
480
+ created_at_ms=request.created_at_ms,
481
+ attachment_paths=tuple(request.attachment_paths),
482
+ reply_to_message_id=request.reply_to_message_id,
365
483
  )
484
+ if isinstance(result, MessageSendHandoffRequired):
485
+ return {
486
+ "ok": True,
487
+ "result": {
488
+ "handoff_required": {"target": result.target},
489
+ },
490
+ }
366
491
  return {
367
492
  "ok": True,
368
493
  "result": {"outbound": serialize_outbound(result)},
369
494
  }
370
495
 
371
496
  if resource == "thread" and command == "unfollow":
372
- target = request.get("target")
373
- if not isinstance(target, str) or not target:
374
- raise CommandDispatchError(
375
- "TARGET_REQUIRED", "target must be a non-empty string"
376
- )
377
- changed = await self._service.unfollow(session_id, target=target)
497
+ request = cast(_ThreadUnfollowRequest, request)
498
+ changed = await self._service.unfollow(session_id, target=request.target)
378
499
  return {"ok": True, "result": {"changed": changed}}
379
500
 
380
501
  raise AssertionError("validated command route has no handler")