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.
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/PKG-INFO +1 -1
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/pyproject.toml +1 -1
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/pyproject.toml.orig +1 -1
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/application.py +42 -15
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/command.py +29 -9
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/app/reminder_dispatch.py +411 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/bcc.py +913 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/__init__.py +2 -1
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/plugin.py +1 -1
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_codec.py +63 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_database.py +21 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_migration.py +85 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/contrib/sqlite/reminder_repository.py +420 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/command.py +54 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/instruction.py +46 -10
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/__init__.py +7 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/models/reminder.py +282 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/states.py +13 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/__init__.py +2 -1
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/reminder.py +250 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/orchestration/reminder_command.py +362 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/session.py +182 -59
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/turn.py +17 -8
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/reminder.py +342 -0
- bazaar_compute_node-0.1.8/src/bazaar_compute_node/core/reminder_schedule.py +330 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/storage.py +88 -0
- bazaar_compute_node-0.1.7/src/bazaar_compute_node/bcc.py +0 -529
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/LICENSE +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/README.md +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/attachments.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/config.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/registry.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/transport.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/windows_pipe.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/wrapper.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/cli.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/approval.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/client.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/events.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/plugin.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/process.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/protocol.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/codex/runtime.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/codec.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/database.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/migrations.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/sqlite/repository.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/channel.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/outbound.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/__init__.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/approval.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/audit.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/channel.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/client.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/concurrency.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/correlation.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/lifecycle.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/models/entities.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/observability.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/command.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/orchestration/services.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/outcomes.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/paths.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/runtime.py +0 -0
- {bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/core/timerwheel.py +0 -0
{bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/application.py
RENAMED
|
@@ -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.
|
|
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.
|
|
211
|
+
await self.command_dispatcher.drain(
|
|
212
|
+
timeout=self.timeout_budget.shutdown_seconds,
|
|
213
|
+
)
|
|
195
214
|
finally:
|
|
196
215
|
try:
|
|
197
|
-
await self.
|
|
216
|
+
await self.command_server.stop()
|
|
198
217
|
finally:
|
|
199
|
-
|
|
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.
|
|
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.
|
|
236
|
+
await self.orchestrator.stop(
|
|
237
|
+
timeout=self.timeout_budget.shutdown_seconds,
|
|
238
|
+
)
|
|
215
239
|
finally:
|
|
216
240
|
try:
|
|
217
|
-
await self.
|
|
241
|
+
await self.command_server.stop()
|
|
218
242
|
finally:
|
|
219
243
|
try:
|
|
220
|
-
await self.
|
|
244
|
+
await self._cleanup_bcc_wrapper()
|
|
221
245
|
finally:
|
|
222
|
-
|
|
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
|
{bazaar_compute_node-0.1.7 → bazaar_compute_node-0.1.8}/src/bazaar_compute_node/app/command.py
RENAMED
|
@@ -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
|
-
|
|
226
|
-
if not isinstance(
|
|
225
|
+
resource = request.get("resource")
|
|
226
|
+
if not isinstance(resource, str) or not resource:
|
|
227
227
|
raise CommandDispatchError(
|
|
228
|
-
"
|
|
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
|
|
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
|
+
]
|