bazaar-compute-node 0.1.7__tar.gz → 0.1.8__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 (75) hide show
  1. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/PKG-INFO +1 -1
  2. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/pyproject.toml +1 -1
  3. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/pyproject.toml.orig +1 -1
  4. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/application.py +42 -15
  5. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/command.py +29 -9
  6. bazaar_compute_node-0.1.8/src/bazaar_compute_node/app/reminder_dispatch.py +411 -0
  7. bazaar_compute_node-0.1.8/src/bazaar_compute_node/bcc.py +913 -0
  8. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/__init__.py +2 -1
  9. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/plugin.py +1 -1
  10. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_codec.py +63 -0
  11. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_database.py +21 -0
  12. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_migration.py +85 -0
  13. bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_repository.py +420 -0
  14. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/command.py +54 -0
  15. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/instruction.py +46 -10
  16. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/__init__.py +7 -0
  17. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/models/reminder.py +282 -0
  18. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/states.py +13 -0
  19. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/__init__.py +2 -1
  20. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/reminder.py +250 -0
  21. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/reminder_command.py +362 -0
  22. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/session.py +182 -59
  23. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/turn.py +17 -8
  24. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/reminder.py +342 -0
  25. bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/reminder_schedule.py +330 -0
  26. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/storage.py +88 -0
  27. bazaar_compute_node-0.1.7/src/bazaar_compute_node/bcc.py +0 -529
  28. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/LICENSE +0 -0
  29. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/README.md +0 -0
  30. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/__init__.py +0 -0
  31. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/__init__.py +0 -0
  32. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/attachments.py +0 -0
  33. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/config.py +0 -0
  34. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/registry.py +0 -0
  35. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/transport.py +0 -0
  36. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/windows_pipe.py +0 -0
  37. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/wrapper.py +0 -0
  38. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/cli.py +0 -0
  39. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  40. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/__init__.py +0 -0
  41. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/approval.py +0 -0
  42. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/client.py +0 -0
  43. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/events.py +0 -0
  44. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/plugin.py +0 -0
  45. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/process.py +0 -0
  46. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/protocol.py +0 -0
  47. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/runtime.py +0 -0
  48. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  49. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  50. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  51. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/codec.py +0 -0
  52. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/database.py +0 -0
  53. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/migrations.py +0 -0
  54. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/repository.py +0 -0
  55. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  56. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/channel.py +0 -0
  57. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  58. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/outbound.py +0 -0
  59. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
  60. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/__init__.py +0 -0
  61. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/approval.py +0 -0
  62. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/audit.py +0 -0
  63. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/channel.py +0 -0
  64. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/client.py +0 -0
  65. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/concurrency.py +0 -0
  66. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/correlation.py +0 -0
  67. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  68. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/entities.py +0 -0
  69. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/observability.py +0 -0
  70. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/command.py +0 -0
  71. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/services.py +0 -0
  72. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/outcomes.py +0 -0
  73. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/paths.py +0 -0
  74. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/runtime.py +0 -0
  75. {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/timerwheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.7
3
+ Version: 0.1.8
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -11,20 +11,20 @@ from collections.abc import Mapping, Sequence
11
11
  from pathlib import Path
12
12
 
13
13
  from ..core.channel import ChannelContext, IChannel
14
+ from ..core.concurrency import SessionLockRegistry
14
15
  from ..core.lifecycle import TimeoutBudget
15
16
  from ..core.models import RuntimeSession
16
17
  from ..core.observability import IAudit
17
- from ..core.orchestration import SessionOrchestrator
18
+ from ..core.orchestration import ReminderScheduler, SessionOrchestrator
19
+ from ..core.orchestration.reminder_command import ReminderCommandService
18
20
  from ..core.paths import resolve_data_dir, resolve_workspace_dir
19
21
  from ..core.runtime import IRuntime, RuntimeCommandContext, RuntimeSandboxMode
20
22
  from ..core.storage import IStorage, NodeIdentity
21
23
  from ..core.timerwheel import TimerWheel
22
24
  from .attachments import AttachmentMaterializer
23
- from .command import (
24
- CommandDispatcher,
25
- CommandDispatchError,
26
- )
25
+ from .command import CommandDispatchError
27
26
  from .registry import AdapterFactories
27
+ from .reminder_dispatch import CommandDispatcher
28
28
  from .transport import LocalCommandServer
29
29
  from .wrapper import install_bcc_wrapper, remove_bcc_wrapper
30
30
 
@@ -85,6 +85,7 @@ class NodeApplication:
85
85
  self._wrapper_path: Path | None = None
86
86
  self._identity: NodeIdentity | None = None
87
87
  self._session_capabilities: dict[str, tuple[str, str]] = {}
88
+ self._concurrency = SessionLockRegistry()
88
89
  self._started = False
89
90
  self._stopped = asyncio.Event()
90
91
  self._runtime_environment_include = tuple(runtime_environment_include)
@@ -136,14 +137,27 @@ class NodeApplication:
136
137
  timer_wheel=self.timer_wheel,
137
138
  runtime_idle_timeout_ms=runtime_idle_timeout_ms,
138
139
  workspace=self._workspace_path,
140
+ concurrency=self._concurrency,
139
141
  on_node_initialized=self._ensure_workspace,
140
142
  )
143
+ self.reminder_scheduler = ReminderScheduler(
144
+ storage=self.storage,
145
+ timer_wheel=self.timer_wheel,
146
+ concurrency=self._concurrency,
147
+ publish_wake=self.orchestrator.publish_reminder_wake,
148
+ )
149
+ self.reminder_service = ReminderCommandService(
150
+ storage=self.storage,
151
+ concurrency=self._concurrency,
152
+ poke=self.reminder_scheduler.poke,
153
+ )
141
154
  self.command_service = self.orchestrator.command_service
142
155
  control_handler = None
143
156
  if factories.control is not None:
144
157
  control_handler = factories.control(self._adapter_context())
145
158
  self.command_dispatcher = CommandDispatcher(
146
159
  self.command_service,
160
+ reminder_service=self.reminder_service,
147
161
  timeout_budget=self.timeout_budget,
148
162
  control_handler=self._handle_control,
149
163
  session_binding_validator=self._validate_session_binding,
@@ -173,30 +187,38 @@ class NodeApplication:
173
187
  await self.orchestrator.start(
174
188
  timeout=self.timeout_budget.startup_seconds,
175
189
  )
190
+ self._started = True
191
+ await self.reminder_scheduler.start(
192
+ timeout=self.timeout_budget.startup_seconds,
193
+ )
176
194
  except BaseException:
177
195
  try:
178
196
  await self.stop()
179
197
  finally:
180
198
  await self.timer_wheel.close()
181
199
  raise
182
- self._started = True
183
200
  self._stopped.clear()
184
201
  self.command_dispatcher.start_accepting()
185
202
 
186
203
  async def stop(self) -> None:
187
204
  if not self._started:
188
205
  try:
189
- await self.command_dispatcher.drain(
206
+ await self.reminder_scheduler.stop(
190
207
  timeout=self.timeout_budget.shutdown_seconds,
191
208
  )
192
209
  finally:
193
210
  try:
194
- await self.command_server.stop()
211
+ await self.command_dispatcher.drain(
212
+ timeout=self.timeout_budget.shutdown_seconds,
213
+ )
195
214
  finally:
196
215
  try:
197
- await self._cleanup_bcc_wrapper()
216
+ await self.command_server.stop()
198
217
  finally:
199
- await self.timer_wheel.close()
218
+ try:
219
+ await self._cleanup_bcc_wrapper()
220
+ finally:
221
+ await self.timer_wheel.close()
200
222
  return
201
223
  self._started = False
202
224
  self.command_dispatcher.stop_accepting()
@@ -206,20 +228,25 @@ class NodeApplication:
206
228
  )
207
229
  finally:
208
230
  try:
209
- await self.orchestrator.stop(
231
+ await self.reminder_scheduler.stop(
210
232
  timeout=self.timeout_budget.shutdown_seconds,
211
233
  )
212
234
  finally:
213
235
  try:
214
- await self.command_server.stop()
236
+ await self.orchestrator.stop(
237
+ timeout=self.timeout_budget.shutdown_seconds,
238
+ )
215
239
  finally:
216
240
  try:
217
- await self._cleanup_bcc_wrapper()
241
+ await self.command_server.stop()
218
242
  finally:
219
243
  try:
220
- await self.timer_wheel.close()
244
+ await self._cleanup_bcc_wrapper()
221
245
  finally:
222
- self._stopped.set()
246
+ try:
247
+ await self.timer_wheel.close()
248
+ finally:
249
+ self._stopped.set()
223
250
 
224
251
  async def _cleanup_bcc_wrapper(self) -> None:
225
252
  wrapper_path = self._wrapper_path
@@ -100,7 +100,7 @@ SessionBindingValidator = Callable[[str, Mapping[str, object]], Awaitable[None]]
100
100
 
101
101
 
102
102
  class CommandDispatcher:
103
- """Translate local JSON requests into core command results."""
103
+ """Translate resource-scoped local JSON requests into core command results."""
104
104
 
105
105
  def __init__(
106
106
  self,
@@ -222,20 +222,40 @@ class CommandDispatcher:
222
222
  async def _dispatch_command(
223
223
  self, request: Mapping[str, object]
224
224
  ) -> Mapping[str, object]:
225
- session_id = request.get("session_id")
226
- if not isinstance(session_id, str) or not session_id:
225
+ resource = request.get("resource")
226
+ if not isinstance(resource, str) or not resource:
227
227
  raise CommandDispatchError(
228
- "SESSION_REQUIRED", "session_id must be a non-empty string"
228
+ "RESOURCE_REQUIRED", "resource must be a non-empty string"
229
229
  )
230
230
  command = request.get("command")
231
231
  if not isinstance(command, str) or not command:
232
232
  raise CommandDispatchError(
233
233
  "COMMAND_REQUIRED", "command must be a non-empty string"
234
234
  )
235
+ if resource == "message":
236
+ if command not in {"check", "read", "send"}:
237
+ raise CommandDispatchError(
238
+ "UNKNOWN_COMMAND", f"unsupported message command: {command}"
239
+ )
240
+ elif resource == "thread":
241
+ if command != "unfollow":
242
+ raise CommandDispatchError(
243
+ "UNKNOWN_COMMAND", f"unsupported thread command: {command}"
244
+ )
245
+ else:
246
+ raise CommandDispatchError(
247
+ "UNKNOWN_RESOURCE", f"unsupported command resource: {resource}"
248
+ )
249
+
250
+ session_id = request.get("session_id")
251
+ if not isinstance(session_id, str) or not session_id:
252
+ raise CommandDispatchError(
253
+ "SESSION_REQUIRED", "session_id must be a non-empty string"
254
+ )
235
255
  if self._session_binding_validator is not None:
236
256
  await self._session_binding_validator(session_id, request)
237
257
 
238
- if command == "check":
258
+ if resource == "message" and command == "check":
239
259
  result = await self._service.check(session_id)
240
260
  return {
241
261
  "ok": True,
@@ -252,7 +272,7 @@ class CommandDispatcher:
252
272
  },
253
273
  }
254
274
 
255
- if command == "read":
275
+ if resource == "message" and command == "read":
256
276
  target = request.get("target")
257
277
  if not isinstance(target, str) or not target:
258
278
  raise CommandDispatchError(
@@ -290,7 +310,7 @@ class CommandDispatcher:
290
310
  },
291
311
  }
292
312
 
293
- if command == "send":
313
+ if resource == "message" and command == "send":
294
314
  target = request.get("target")
295
315
  body = request.get("body")
296
316
  command_id = request.get("command_id")
@@ -343,7 +363,7 @@ class CommandDispatcher:
343
363
  "result": {"outbound": serialize_outbound(result)},
344
364
  }
345
365
 
346
- if command == "unfollow":
366
+ if resource == "thread" and command == "unfollow":
347
367
  target = request.get("target")
348
368
  if not isinstance(target, str) or not target:
349
369
  raise CommandDispatchError(
@@ -352,7 +372,7 @@ class CommandDispatcher:
352
372
  changed = await self._service.unfollow(session_id, target=target)
353
373
  return {"ok": True, "result": {"changed": changed}}
354
374
 
355
- raise CommandDispatchError("UNKNOWN_COMMAND", f"unsupported command: {command}")
375
+ raise AssertionError("validated command route has no handler")
356
376
 
357
377
 
358
378
  def format_message_time(timestamp_ms: int) -> str:
@@ -0,0 +1,411 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from time import time_ns
5
+
6
+ from ..core.command import ICommandService, IReminderService
7
+ from ..core.lifecycle import TimeoutBudget
8
+ from ..core.models import Reminder, ReminderOccurrence, ReminderState
9
+ from ..core.orchestration.reminder_command import ReminderCommandFailure
10
+ from ..core.reminder import (
11
+ ReminderCancelRequest,
12
+ ReminderCheckRequest,
13
+ ReminderListRequest,
14
+ ReminderScheduleRequest,
15
+ ReminderSnoozeRequest,
16
+ ReminderUpdateRequest,
17
+ )
18
+ from .command import CommandDispatcher as _MessageCommandDispatcher
19
+ from .command import (
20
+ CommandDispatchError,
21
+ ControlHandler,
22
+ SessionBindingValidator,
23
+ )
24
+
25
+
26
+ def serialize_reminder(reminder: Reminder) -> dict[str, object]:
27
+ return {
28
+ "reminder_id": reminder.reminder_id,
29
+ "owner_session_id": reminder.owner_session_id,
30
+ "anchor_message_id": reminder.anchor_message_id,
31
+ "title": reminder.title,
32
+ "state": reminder.state.value,
33
+ "next_fire_at_ms": reminder.next_fire_at_ms,
34
+ "repeat_rule": reminder.repeat_rule,
35
+ "timezone": reminder.timezone,
36
+ "revision": reminder.revision,
37
+ "last_occurrence_no": reminder.last_occurrence_no,
38
+ "created_at_ms": reminder.created_at_ms,
39
+ "updated_at_ms": reminder.updated_at_ms,
40
+ "last_fired_at_ms": reminder.last_fired_at_ms,
41
+ "canceled_at_ms": reminder.canceled_at_ms,
42
+ }
43
+
44
+
45
+ def serialize_reminder_occurrence(
46
+ occurrence: ReminderOccurrence,
47
+ ) -> dict[str, object]:
48
+ return {
49
+ "occurrence_id": occurrence.occurrence_id,
50
+ "reminder_id": occurrence.reminder_id,
51
+ "owner_session_id": occurrence.owner_session_id,
52
+ "occurrence_no": occurrence.occurrence_no,
53
+ "anchor_message_id": occurrence.anchor_message_id,
54
+ "scheduled_for_ms": occurrence.scheduled_for_ms,
55
+ "fired_at_ms": occurrence.fired_at_ms,
56
+ "next_fire_at_ms": occurrence.next_fire_at_ms,
57
+ "overdue": occurrence.overdue,
58
+ "read_at_ms": occurrence.read_at_ms,
59
+ "created_at_ms": occurrence.created_at_ms,
60
+ }
61
+
62
+
63
+ class CommandDispatcher(_MessageCommandDispatcher):
64
+ """Resource-aware command dispatcher for message, thread, and Reminder commands."""
65
+
66
+ def __init__(
67
+ self,
68
+ service: ICommandService,
69
+ *,
70
+ reminder_service: IReminderService,
71
+ timeout_budget: TimeoutBudget,
72
+ control_handler: ControlHandler | None = None,
73
+ session_binding_validator: SessionBindingValidator | None = None,
74
+ ) -> None:
75
+ super().__init__(
76
+ service,
77
+ timeout_budget=timeout_budget,
78
+ control_handler=control_handler,
79
+ session_binding_validator=session_binding_validator,
80
+ )
81
+ self._reminder_service = reminder_service
82
+
83
+ async def _dispatch_command(
84
+ self,
85
+ request: Mapping[str, object],
86
+ ) -> Mapping[str, object]:
87
+ resource = request.get("resource")
88
+ if not isinstance(resource, str) or not resource:
89
+ raise CommandDispatchError(
90
+ "RESOURCE_REQUIRED",
91
+ "resource must be a non-empty string",
92
+ )
93
+ command = request.get("command")
94
+ if not isinstance(command, str) or not command:
95
+ raise CommandDispatchError(
96
+ "COMMAND_REQUIRED",
97
+ "command must be a non-empty string",
98
+ )
99
+
100
+ if resource == "message":
101
+ if command not in {"check", "read", "send"}:
102
+ raise CommandDispatchError(
103
+ "UNKNOWN_COMMAND",
104
+ f"unsupported message command: {command}",
105
+ )
106
+ return await super()._dispatch_command(request)
107
+ if resource == "thread":
108
+ if command != "unfollow":
109
+ raise CommandDispatchError(
110
+ "UNKNOWN_COMMAND",
111
+ f"unsupported thread command: {command}",
112
+ )
113
+ return await super()._dispatch_command(request)
114
+ if resource != "reminder":
115
+ raise CommandDispatchError(
116
+ "UNKNOWN_RESOURCE",
117
+ f"unsupported command resource: {resource}",
118
+ )
119
+
120
+ session_id = request.get("session_id")
121
+ if not isinstance(session_id, str) or not session_id:
122
+ raise CommandDispatchError(
123
+ "SESSION_REQUIRED",
124
+ "session_id must be a non-empty string",
125
+ )
126
+ if self._session_binding_validator is not None:
127
+ await self._session_binding_validator(session_id, request)
128
+
129
+ try:
130
+ return await self._dispatch_reminder(session_id, command, request)
131
+ except ReminderCommandFailure as error:
132
+ raise CommandDispatchError(
133
+ error.code,
134
+ error.message,
135
+ next_action=error.next_action,
136
+ ) from error
137
+
138
+ async def _dispatch_reminder(
139
+ self,
140
+ session_id: str,
141
+ command: str,
142
+ request: Mapping[str, object],
143
+ ) -> Mapping[str, object]:
144
+ now_ms = time_ns() // 1_000_000
145
+ if command == "schedule":
146
+ title = request.get("title")
147
+ message_id = request.get("message_id")
148
+ delay_seconds = request.get("delay_seconds")
149
+ fire_at = request.get("fire_at")
150
+ repeat_rule = request.get("repeat_rule")
151
+ timezone = request.get("timezone")
152
+ if not isinstance(title, str) or not title:
153
+ raise CommandDispatchError(
154
+ "REMINDER_TITLE_REQUIRED",
155
+ "Reminder title must be a non-empty string.",
156
+ )
157
+ if not isinstance(message_id, str) or not message_id:
158
+ raise CommandDispatchError(
159
+ "REMINDER_ANCHOR_REQUIRED",
160
+ "Reminder --message-id is required.",
161
+ )
162
+ if delay_seconds is not None and (
163
+ isinstance(delay_seconds, bool)
164
+ or not isinstance(delay_seconds, int)
165
+ or delay_seconds <= 0
166
+ ):
167
+ raise CommandDispatchError(
168
+ "REMINDER_TIME_REQUIRED",
169
+ "--delay-seconds must be a positive integer.",
170
+ )
171
+ if fire_at is not None and (not isinstance(fire_at, str) or not fire_at):
172
+ raise CommandDispatchError(
173
+ "REMINDER_TIME_REQUIRED",
174
+ "--fire-at must be a non-empty ISO-8601 timestamp.",
175
+ )
176
+ if repeat_rule is not None and (
177
+ not isinstance(repeat_rule, str) or not repeat_rule
178
+ ):
179
+ raise CommandDispatchError(
180
+ "REMINDER_REPEAT_INVALID",
181
+ "--repeat must be a non-empty recurrence rule.",
182
+ )
183
+ if timezone is not None and (not isinstance(timezone, str) or not timezone):
184
+ raise CommandDispatchError(
185
+ "REMINDER_TIMEZONE_INVALID",
186
+ "--tz must be a non-empty IANA timezone.",
187
+ )
188
+ if delay_seconds is not None and fire_at is not None:
189
+ raise CommandDispatchError(
190
+ "REMINDER_TIME_CONFLICT",
191
+ "--delay-seconds and --fire-at are mutually exclusive.",
192
+ )
193
+ if delay_seconds is None and fire_at is None and repeat_rule is None:
194
+ raise CommandDispatchError(
195
+ "REMINDER_TIME_REQUIRED",
196
+ "A Reminder requires --delay-seconds, --fire-at, or --repeat.",
197
+ )
198
+ try:
199
+ typed = ReminderScheduleRequest.from_options(
200
+ title=title,
201
+ message_id=message_id,
202
+ evaluated_at_ms=now_ms,
203
+ delay_seconds=delay_seconds,
204
+ fire_at=fire_at,
205
+ repeat_rule=repeat_rule,
206
+ timezone=timezone,
207
+ )
208
+ except ValueError as error:
209
+ message = str(error)
210
+ lowered = message.casefold()
211
+ if "title" in lowered:
212
+ code = "REMINDER_TITLE_REQUIRED"
213
+ elif "timezone" in lowered:
214
+ code = "REMINDER_TIMEZONE_INVALID"
215
+ elif "repeat" in lowered or "weekday" in lowered:
216
+ code = "REMINDER_REPEAT_INVALID"
217
+ elif "id reference" in lowered or "uuid" in lowered:
218
+ code = "REMINDER_ANCHOR_NOT_FOUND"
219
+ else:
220
+ code = "REMINDER_TIME_REQUIRED"
221
+ raise CommandDispatchError(code, message) from error
222
+ result = await self._reminder_service.schedule(session_id, typed)
223
+ return {
224
+ "ok": True,
225
+ "result": {"reminder": serialize_reminder(result.reminder)},
226
+ }
227
+
228
+ if command == "check":
229
+ result = await self._reminder_service.check(
230
+ session_id,
231
+ ReminderCheckRequest(),
232
+ )
233
+ return {
234
+ "ok": True,
235
+ "result": {
236
+ "items": [
237
+ {
238
+ "occurrence": serialize_reminder_occurrence(
239
+ item.occurrence
240
+ ),
241
+ "title": item.title,
242
+ "canonical_target": item.canonical_target,
243
+ }
244
+ for item in result.items
245
+ ],
246
+ "has_more": result.has_more,
247
+ },
248
+ }
249
+
250
+ if command == "list":
251
+ all_statuses = request.get("all", False)
252
+ status_text = request.get("status")
253
+ if not isinstance(all_statuses, bool):
254
+ raise CommandDispatchError(
255
+ "INVALID_COMMAND",
256
+ "reminder list all flag must be boolean",
257
+ )
258
+ if status_text is not None and (
259
+ not isinstance(status_text, str) or not status_text
260
+ ):
261
+ raise CommandDispatchError(
262
+ "INVALID_COMMAND",
263
+ "--status must be a non-empty comma-separated status list",
264
+ )
265
+ if all_statuses and status_text is not None:
266
+ raise CommandDispatchError(
267
+ "INVALID_COMMAND",
268
+ "--all and --status cannot be used together",
269
+ )
270
+ if all_statuses:
271
+ statuses = frozenset(ReminderState)
272
+ elif status_text is None:
273
+ statuses = ReminderListRequest().statuses
274
+ else:
275
+ try:
276
+ statuses = frozenset(
277
+ ReminderState(value.strip())
278
+ for value in status_text.split(",")
279
+ if value.strip()
280
+ )
281
+ except ValueError as error:
282
+ raise CommandDispatchError(
283
+ "INVALID_COMMAND",
284
+ f"invalid Reminder status list: {status_text}",
285
+ ) from error
286
+ if not statuses:
287
+ raise CommandDispatchError(
288
+ "INVALID_COMMAND",
289
+ "--status must contain at least one Reminder status",
290
+ )
291
+ result = await self._reminder_service.list(
292
+ session_id,
293
+ ReminderListRequest(statuses=statuses),
294
+ )
295
+ return {
296
+ "ok": True,
297
+ "result": {
298
+ "reminders": [
299
+ serialize_reminder(reminder) for reminder in result.reminders
300
+ ]
301
+ },
302
+ }
303
+
304
+ if command == "snooze":
305
+ reminder_id = self._required_reminder_id(request)
306
+ duration = request.get("by")
307
+ if not isinstance(duration, str) or not duration:
308
+ raise CommandDispatchError(
309
+ "INVALID_COMMAND",
310
+ "Reminder snooze requires --by <duration>.",
311
+ )
312
+ try:
313
+ typed = ReminderSnoozeRequest.from_options(
314
+ reminder_id=reminder_id,
315
+ duration=duration,
316
+ evaluated_at_ms=now_ms,
317
+ )
318
+ except ValueError as error:
319
+ code = (
320
+ "REMINDER_NOT_FOUND"
321
+ if self._is_id_error(error)
322
+ else "INVALID_COMMAND"
323
+ )
324
+ raise CommandDispatchError(code, str(error)) from error
325
+ result = await self._reminder_service.snooze(session_id, typed)
326
+ return {
327
+ "ok": True,
328
+ "result": {"reminder": serialize_reminder(result.reminder)},
329
+ }
330
+
331
+ if command == "update":
332
+ reminder_id = self._required_reminder_id(request)
333
+ fire_at = request.get("fire_at")
334
+ in_duration = request.get("in")
335
+ cadence = request.get("cadence")
336
+ title = request.get("title")
337
+ for value, field_name in (
338
+ (fire_at, "fire_at"),
339
+ (in_duration, "in"),
340
+ (cadence, "cadence"),
341
+ (title, "title"),
342
+ ):
343
+ if value is not None and (not isinstance(value, str) or not value):
344
+ raise CommandDispatchError(
345
+ "REMINDER_UPDATE_FAILED",
346
+ f"Reminder update {field_name} must be non-empty text.",
347
+ )
348
+ try:
349
+ typed = ReminderUpdateRequest.from_options(
350
+ reminder_id=reminder_id,
351
+ evaluated_at_ms=now_ms,
352
+ fire_at=fire_at if isinstance(fire_at, str) else None,
353
+ in_duration=(in_duration if isinstance(in_duration, str) else None),
354
+ cadence=cadence if isinstance(cadence, str) else None,
355
+ title=title if isinstance(title, str) else None,
356
+ )
357
+ except ValueError as error:
358
+ if self._is_id_error(error):
359
+ code = "REMINDER_NOT_FOUND"
360
+ elif cadence is not None:
361
+ code = "REMINDER_REPEAT_INVALID"
362
+ else:
363
+ code = "REMINDER_UPDATE_FAILED"
364
+ raise CommandDispatchError(code, str(error)) from error
365
+ result = await self._reminder_service.update(session_id, typed)
366
+ return {
367
+ "ok": True,
368
+ "result": {"reminder": serialize_reminder(result.reminder)},
369
+ }
370
+
371
+ if command == "cancel":
372
+ reminder_id = self._required_reminder_id(request)
373
+ try:
374
+ typed = ReminderCancelRequest(
375
+ reminder_id=reminder_id,
376
+ evaluated_at_ms=now_ms,
377
+ )
378
+ except ValueError as error:
379
+ raise CommandDispatchError("REMINDER_NOT_FOUND", str(error)) from error
380
+ result = await self._reminder_service.cancel(session_id, typed)
381
+ return {
382
+ "ok": True,
383
+ "result": {"reminder": serialize_reminder(result.reminder)},
384
+ }
385
+
386
+ raise CommandDispatchError(
387
+ "UNKNOWN_COMMAND",
388
+ f"unsupported reminder command: {command}",
389
+ )
390
+
391
+ @staticmethod
392
+ def _required_reminder_id(request: Mapping[str, object]) -> str:
393
+ reminder_id = request.get("reminder_id")
394
+ if not isinstance(reminder_id, str) or not reminder_id:
395
+ raise CommandDispatchError(
396
+ "REMINDER_NOT_FOUND",
397
+ "Reminder --id is required.",
398
+ )
399
+ return reminder_id
400
+
401
+ @staticmethod
402
+ def _is_id_error(error: ValueError) -> bool:
403
+ lowered = str(error).casefold()
404
+ return "id reference" in lowered or "uuid" in lowered
405
+
406
+
407
+ __all__ = [
408
+ "CommandDispatcher",
409
+ "serialize_reminder",
410
+ "serialize_reminder_occurrence",
411
+ ]