netizen-cli 0.10.0__py3-none-any.whl
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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
netizen_cli/bindings.py
ADDED
|
@@ -0,0 +1,3215 @@
|
|
|
1
|
+
"""Persistence for Feishu bindings and the Channel SDK dedup extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from contextlib import nullcontext
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TypeVar
|
|
22
|
+
|
|
23
|
+
from .domain import (
|
|
24
|
+
FeishuScope,
|
|
25
|
+
MentionContextMode,
|
|
26
|
+
MessageContextAnchor,
|
|
27
|
+
ScopeKind,
|
|
28
|
+
)
|
|
29
|
+
from .defaults.store import (
|
|
30
|
+
DefaultsStore, create_schema as create_defaults_schema,
|
|
31
|
+
require_schema as require_defaults_schema,
|
|
32
|
+
)
|
|
33
|
+
from .schedules.models import Run, ScheduleConflict
|
|
34
|
+
from .schedules.store import (
|
|
35
|
+
ScheduleStore, create_schema, require_schema,
|
|
36
|
+
)
|
|
37
|
+
from .session_settings import BindingTaskFeedback, BindingTurnSettings, SessionSettings
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SCHEMA_VERSION = 14
|
|
41
|
+
|
|
42
|
+
# Includes the running query and all submitted queries waiting for the reader.
|
|
43
|
+
_ADMIN_QUERY_CAPACITY = 32
|
|
44
|
+
PROJECT_DELETE_LIMIT = 1000
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CONTEXT_INTEGRITY_TRIGGERS = (
|
|
48
|
+
"""
|
|
49
|
+
CREATE TRIGGER IF NOT EXISTS bindings_context_shape_insert
|
|
50
|
+
BEFORE INSERT ON bindings
|
|
51
|
+
WHEN NOT (
|
|
52
|
+
(
|
|
53
|
+
NEW.message_context_mode = 'current-only'
|
|
54
|
+
AND NEW.context_anchor_message_id IS NULL
|
|
55
|
+
AND NEW.context_anchor_create_time_ms IS NULL
|
|
56
|
+
) OR (
|
|
57
|
+
NEW.message_context_mode = 'catch-up'
|
|
58
|
+
AND NEW.context_anchor_message_id IS NOT NULL
|
|
59
|
+
AND length(NEW.context_anchor_message_id) > 0
|
|
60
|
+
AND NEW.context_anchor_create_time_ms IS NOT NULL
|
|
61
|
+
AND typeof(NEW.context_anchor_create_time_ms) = 'integer'
|
|
62
|
+
AND NEW.context_anchor_create_time_ms > 0
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
BEGIN
|
|
66
|
+
SELECT RAISE(
|
|
67
|
+
ABORT,
|
|
68
|
+
'Binding context must match its mode'
|
|
69
|
+
);
|
|
70
|
+
END
|
|
71
|
+
""",
|
|
72
|
+
"""
|
|
73
|
+
CREATE TRIGGER IF NOT EXISTS bindings_context_shape_update
|
|
74
|
+
BEFORE UPDATE OF message_context_mode,
|
|
75
|
+
context_anchor_message_id, context_anchor_create_time_ms
|
|
76
|
+
ON bindings
|
|
77
|
+
WHEN NOT (
|
|
78
|
+
(
|
|
79
|
+
NEW.message_context_mode = 'current-only'
|
|
80
|
+
AND NEW.context_anchor_message_id IS NULL
|
|
81
|
+
AND NEW.context_anchor_create_time_ms IS NULL
|
|
82
|
+
) OR (
|
|
83
|
+
NEW.message_context_mode = 'catch-up'
|
|
84
|
+
AND NEW.context_anchor_message_id IS NOT NULL
|
|
85
|
+
AND length(NEW.context_anchor_message_id) > 0
|
|
86
|
+
AND NEW.context_anchor_create_time_ms IS NOT NULL
|
|
87
|
+
AND typeof(NEW.context_anchor_create_time_ms) = 'integer'
|
|
88
|
+
AND NEW.context_anchor_create_time_ms > 0
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
BEGIN
|
|
92
|
+
SELECT RAISE(
|
|
93
|
+
ABORT,
|
|
94
|
+
'Binding context must match its mode'
|
|
95
|
+
);
|
|
96
|
+
END
|
|
97
|
+
""",
|
|
98
|
+
"""
|
|
99
|
+
CREATE TRIGGER IF NOT EXISTS bindings_context_scope_insert
|
|
100
|
+
BEFORE INSERT ON bindings
|
|
101
|
+
WHEN NEW.message_context_mode = 'catch-up'
|
|
102
|
+
AND EXISTS (
|
|
103
|
+
SELECT 1
|
|
104
|
+
FROM scopes
|
|
105
|
+
WHERE scope_key = NEW.scope_key
|
|
106
|
+
AND kind = 'direct'
|
|
107
|
+
)
|
|
108
|
+
BEGIN
|
|
109
|
+
SELECT RAISE(
|
|
110
|
+
ABORT,
|
|
111
|
+
'direct Binding cannot use catch-up context'
|
|
112
|
+
);
|
|
113
|
+
END
|
|
114
|
+
""",
|
|
115
|
+
"""
|
|
116
|
+
CREATE TRIGGER IF NOT EXISTS bindings_context_scope_update
|
|
117
|
+
BEFORE UPDATE OF scope_key, message_context_mode,
|
|
118
|
+
context_anchor_message_id, context_anchor_create_time_ms
|
|
119
|
+
ON bindings
|
|
120
|
+
WHEN NEW.message_context_mode = 'catch-up'
|
|
121
|
+
AND EXISTS (
|
|
122
|
+
SELECT 1
|
|
123
|
+
FROM scopes
|
|
124
|
+
WHERE scope_key = NEW.scope_key
|
|
125
|
+
AND kind = 'direct'
|
|
126
|
+
)
|
|
127
|
+
BEGIN
|
|
128
|
+
SELECT RAISE(
|
|
129
|
+
ABORT,
|
|
130
|
+
'direct Binding cannot use catch-up context'
|
|
131
|
+
);
|
|
132
|
+
END
|
|
133
|
+
""",
|
|
134
|
+
"""
|
|
135
|
+
CREATE TRIGGER IF NOT EXISTS scopes_context_kind_update
|
|
136
|
+
BEFORE UPDATE OF kind ON scopes
|
|
137
|
+
WHEN NEW.kind = 'direct'
|
|
138
|
+
AND EXISTS (
|
|
139
|
+
SELECT 1
|
|
140
|
+
FROM bindings
|
|
141
|
+
WHERE scope_key = NEW.scope_key
|
|
142
|
+
AND message_context_mode = 'catch-up'
|
|
143
|
+
)
|
|
144
|
+
BEGIN
|
|
145
|
+
SELECT RAISE(
|
|
146
|
+
ABORT,
|
|
147
|
+
'direct Scope cannot contain catch-up Binding'
|
|
148
|
+
);
|
|
149
|
+
END
|
|
150
|
+
""",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class BindingConflict(RuntimeError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class BindingNotFound(LookupError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class BindingSettingsRevisionConflict(RuntimeError):
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class BindingContextRevisionConflict(RuntimeError):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class BindingFeedbackRevisionConflict(RuntimeError):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class AmbiguousBinding(LookupError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ProjectConflict(RuntimeError):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ProjectNotFound(LookupError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ProjectRevisionConflict(RuntimeError):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ProjectDisabled(ProjectConflict):
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ProjectDeleting(ProjectDisabled):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ProjectInventoryConflict(ProjectConflict):
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ProjectDeleteLimitExceeded(ProjectConflict):
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class SideTopicConflict(RuntimeError):
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SideTopicNotFound(LookupError):
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class BindingQueryBusy(RuntimeError):
|
|
215
|
+
"""The Admin query capacity is full or SQLite cannot acquire a read lock."""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class BindingQueryClosed(RuntimeError):
|
|
219
|
+
"""The Store is draining or closed and accepts no new Admin query."""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class BindingQueryTimeout(TimeoutError):
|
|
223
|
+
"""An Admin query exceeded its combined queue and execution deadline."""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ScopeConflict(RuntimeError):
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ScopeNotFound(LookupError):
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True, slots=True)
|
|
235
|
+
class ThreadBinding:
|
|
236
|
+
id: str
|
|
237
|
+
scope_key: str
|
|
238
|
+
project_alias: str
|
|
239
|
+
native_thread_id: str | None
|
|
240
|
+
turn_settings: BindingTurnSettings | None
|
|
241
|
+
settings_revision: int
|
|
242
|
+
creator_id: str
|
|
243
|
+
active: bool
|
|
244
|
+
created_at: str
|
|
245
|
+
activated_at: str | None
|
|
246
|
+
message_context_mode: MentionContextMode = MentionContextMode.CURRENT_ONLY
|
|
247
|
+
context_anchor: MessageContextAnchor | None = None
|
|
248
|
+
context_revision: int = 1
|
|
249
|
+
task_feedback: BindingTaskFeedback = BindingTaskFeedback()
|
|
250
|
+
feedback_revision: int = 1
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def short_id(self) -> str:
|
|
254
|
+
return self.id[:8]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True, slots=True)
|
|
258
|
+
class ProjectRecord:
|
|
259
|
+
alias: str
|
|
260
|
+
cwd: str
|
|
261
|
+
enabled: bool
|
|
262
|
+
revision: int
|
|
263
|
+
created_at: str
|
|
264
|
+
updated_at: str
|
|
265
|
+
deleted: bool = False
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True, slots=True)
|
|
269
|
+
class ScopeRecord:
|
|
270
|
+
scope_key: str
|
|
271
|
+
app_id: str
|
|
272
|
+
chat_id: str
|
|
273
|
+
kind: ScopeKind
|
|
274
|
+
topic_id: str | None
|
|
275
|
+
active_binding_id: str | None
|
|
276
|
+
updated_at: str
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def scope(self) -> FeishuScope:
|
|
280
|
+
return FeishuScope(
|
|
281
|
+
app_id=self.app_id,
|
|
282
|
+
chat_id=self.chat_id,
|
|
283
|
+
kind=self.kind,
|
|
284
|
+
topic_id=self.topic_id,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True, slots=True)
|
|
289
|
+
class BindingCursor:
|
|
290
|
+
created_at: str
|
|
291
|
+
binding_id: str
|
|
292
|
+
|
|
293
|
+
def __post_init__(self) -> None:
|
|
294
|
+
if not self.created_at or not self.binding_id:
|
|
295
|
+
raise ValueError("Binding cursor fields must not be empty")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass(frozen=True, slots=True)
|
|
299
|
+
class BindingQuery:
|
|
300
|
+
project_alias: str | None = None
|
|
301
|
+
scope_kind: ScopeKind | None = None
|
|
302
|
+
chat_id: str | None = None
|
|
303
|
+
topic_id: str | None = None
|
|
304
|
+
identity: str | None = None
|
|
305
|
+
materialized: bool | None = None
|
|
306
|
+
current: bool | None = None
|
|
307
|
+
created_from: str | None = None
|
|
308
|
+
created_before: str | None = None
|
|
309
|
+
project_aliases: tuple[str, ...] | None = None
|
|
310
|
+
scope_kinds: tuple[ScopeKind, ...] | None = None
|
|
311
|
+
app_id: str | None = None
|
|
312
|
+
search: str | None = None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass(frozen=True, slots=True)
|
|
316
|
+
class BindingInventoryRecord:
|
|
317
|
+
binding: ThreadBinding
|
|
318
|
+
scope: ScopeRecord
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass(frozen=True, slots=True)
|
|
322
|
+
class BindingPage:
|
|
323
|
+
items: tuple[BindingInventoryRecord, ...]
|
|
324
|
+
next_cursor: BindingCursor | None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True, slots=True)
|
|
328
|
+
class SideTopicCursor:
|
|
329
|
+
created_at: str
|
|
330
|
+
side_id: str
|
|
331
|
+
|
|
332
|
+
def __post_init__(self) -> None:
|
|
333
|
+
if not self.created_at or not self.side_id:
|
|
334
|
+
raise ValueError("Side Topic cursor fields must not be empty")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True, slots=True)
|
|
338
|
+
class SideTopicQuery:
|
|
339
|
+
project_alias: str | None = None
|
|
340
|
+
parent_binding_id: str | None = None
|
|
341
|
+
app_id: str | None = None
|
|
342
|
+
chat_id: str | None = None
|
|
343
|
+
topic_id: str | None = None
|
|
344
|
+
root_message_id: str | None = None
|
|
345
|
+
state: SideTopicState | None = None
|
|
346
|
+
created_from: str | None = None
|
|
347
|
+
created_before: str | None = None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True, slots=True)
|
|
351
|
+
class SideTopicInventoryRecord:
|
|
352
|
+
side_topic: SideTopicRecord
|
|
353
|
+
project_alias: str | None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True, slots=True)
|
|
357
|
+
class SideTopicPage:
|
|
358
|
+
items: tuple[SideTopicInventoryRecord, ...]
|
|
359
|
+
next_cursor: SideTopicCursor | None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True, slots=True)
|
|
363
|
+
class ProjectAggregate:
|
|
364
|
+
project: ProjectRecord
|
|
365
|
+
binding_count: int
|
|
366
|
+
lazy_binding_count: int
|
|
367
|
+
materialized_binding_count: int
|
|
368
|
+
last_activated_at: str | None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass(frozen=True, slots=True)
|
|
372
|
+
class ProjectAggregatePage:
|
|
373
|
+
items: tuple[ProjectAggregate, ...]
|
|
374
|
+
next_cursor: str | None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class SideTopicState(str, Enum):
|
|
378
|
+
CREATING = "creating"
|
|
379
|
+
OPEN = "open"
|
|
380
|
+
CLOSED = "closed"
|
|
381
|
+
EXPIRED = "expired"
|
|
382
|
+
FAILED = "failed"
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def terminal(self) -> bool:
|
|
386
|
+
return self in {
|
|
387
|
+
SideTopicState.CLOSED,
|
|
388
|
+
SideTopicState.EXPIRED,
|
|
389
|
+
SideTopicState.FAILED,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass(frozen=True, slots=True)
|
|
394
|
+
class SideTopicRecord:
|
|
395
|
+
id: str
|
|
396
|
+
app_id: str
|
|
397
|
+
chat_id: str
|
|
398
|
+
topic_id: str | None
|
|
399
|
+
root_message_id: str | None
|
|
400
|
+
source_message_id: str
|
|
401
|
+
parent_binding_id: str
|
|
402
|
+
creator_id: str
|
|
403
|
+
requires_mention: bool
|
|
404
|
+
state: SideTopicState
|
|
405
|
+
created_at: str
|
|
406
|
+
updated_at: str
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def short_id(self) -> str:
|
|
410
|
+
return self.id[:8]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@dataclass(frozen=True, slots=True)
|
|
414
|
+
class ProjectDeleteSnapshot:
|
|
415
|
+
project: ProjectRecord
|
|
416
|
+
bindings: tuple[BindingInventoryRecord, ...]
|
|
417
|
+
sides: tuple[SideTopicRecord, ...]
|
|
418
|
+
fingerprint: str
|
|
419
|
+
scheduled_plans: tuple[tuple[str, int], ...] = ()
|
|
420
|
+
scheduled_runs: tuple[Run, ...] = ()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def validate_channel_database(path: str | Path, *, allow_empty: bool = False) -> None:
|
|
424
|
+
"""Validate an existing current database without opening a writer or repairing it."""
|
|
425
|
+
database = Path(path)
|
|
426
|
+
if database.is_symlink() or not database.is_file():
|
|
427
|
+
raise RuntimeError("Channel database must be a regular file")
|
|
428
|
+
connection = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True)
|
|
429
|
+
connection.row_factory = sqlite3.Row
|
|
430
|
+
try:
|
|
431
|
+
connection.execute("PRAGMA query_only = ON")
|
|
432
|
+
if allow_empty and connection.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone() is None:
|
|
433
|
+
return
|
|
434
|
+
_require_current_schema(connection)
|
|
435
|
+
_require_database_integrity(connection)
|
|
436
|
+
except sqlite3.Error as error:
|
|
437
|
+
raise RuntimeError(f"Channel database validation failed: {error}") from error
|
|
438
|
+
finally:
|
|
439
|
+
connection.close()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _require_current_schema(connection: sqlite3.Connection) -> None:
|
|
443
|
+
versions = connection.execute("SELECT version FROM schema_version").fetchall()
|
|
444
|
+
if len(versions) != 1 or versions[0]["version"] != SCHEMA_VERSION:
|
|
445
|
+
raise RuntimeError(
|
|
446
|
+
"unsupported channel database schema version; "
|
|
447
|
+
"only the current schema is supported by the service; "
|
|
448
|
+
"restart through Netizen startup preparation to migrate supported databases; existing data was not changed"
|
|
449
|
+
)
|
|
450
|
+
_require_project_metadata_schema(connection)
|
|
451
|
+
require_schema(connection)
|
|
452
|
+
require_defaults_schema(connection)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _require_project_metadata_schema(
|
|
456
|
+
connection: sqlite3.Connection,
|
|
457
|
+
) -> dict[str, sqlite3.Row]:
|
|
458
|
+
"""Validate the current permanent metadata tables."""
|
|
459
|
+
required_tables = {
|
|
460
|
+
"schema_version", "scopes", "bindings", "projects", "dedup_keys", "side_topics",
|
|
461
|
+
}
|
|
462
|
+
actual_tables = {
|
|
463
|
+
row["name"] for row in connection.execute(
|
|
464
|
+
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
|
465
|
+
).fetchall()
|
|
466
|
+
}
|
|
467
|
+
if required_tables - actual_tables:
|
|
468
|
+
raise RuntimeError(
|
|
469
|
+
"Channel database is missing required tables: "
|
|
470
|
+
+ ", ".join(sorted(required_tables - actual_tables))
|
|
471
|
+
)
|
|
472
|
+
_require_binding_columns(connection)
|
|
473
|
+
project_schema = {
|
|
474
|
+
row["name"]: row
|
|
475
|
+
for row in connection.execute("PRAGMA table_info(projects)").fetchall()
|
|
476
|
+
}
|
|
477
|
+
required = {"alias", "cwd", "enabled", "revision", "created_at", "updated_at"}
|
|
478
|
+
if not required.issubset(project_schema):
|
|
479
|
+
raise RuntimeError("Channel database is missing required Project columns")
|
|
480
|
+
if "deleted" not in project_schema:
|
|
481
|
+
raise RuntimeError("current Channel database is missing Project tombstones")
|
|
482
|
+
deleted = project_schema["deleted"]
|
|
483
|
+
if (
|
|
484
|
+
deleted["type"].upper() != "INTEGER"
|
|
485
|
+
or deleted["notnull"] != 1
|
|
486
|
+
or deleted["dflt_value"] != "0"
|
|
487
|
+
):
|
|
488
|
+
raise RuntimeError("current Project tombstone column has invalid shape")
|
|
489
|
+
invalid = connection.execute(
|
|
490
|
+
"SELECT 1 FROM projects WHERE typeof(deleted) != 'integer' "
|
|
491
|
+
"OR deleted NOT IN (0, 1) LIMIT 1"
|
|
492
|
+
).fetchone()
|
|
493
|
+
if invalid is not None:
|
|
494
|
+
raise RuntimeError("current Project tombstone values are invalid")
|
|
495
|
+
return project_schema
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _require_binding_columns(connection: sqlite3.Connection) -> None:
|
|
499
|
+
columns = {
|
|
500
|
+
row["name"]: row
|
|
501
|
+
for row in connection.execute("PRAGMA table_info(bindings)").fetchall()
|
|
502
|
+
}
|
|
503
|
+
required = {
|
|
504
|
+
"message_context_mode",
|
|
505
|
+
"context_anchor_message_id",
|
|
506
|
+
"context_anchor_create_time_ms",
|
|
507
|
+
"context_revision",
|
|
508
|
+
"task_reactions_enabled",
|
|
509
|
+
"progress_card_enabled",
|
|
510
|
+
"completion_mention_enabled",
|
|
511
|
+
"feedback_revision",
|
|
512
|
+
"ever_activated",
|
|
513
|
+
}
|
|
514
|
+
missing = required - columns.keys()
|
|
515
|
+
if missing:
|
|
516
|
+
raise RuntimeError(
|
|
517
|
+
"current Channel database is missing Binding columns: "
|
|
518
|
+
+ ", ".join(sorted(missing))
|
|
519
|
+
)
|
|
520
|
+
mention = columns["completion_mention_enabled"]
|
|
521
|
+
if (
|
|
522
|
+
mention["type"].upper() != "INTEGER"
|
|
523
|
+
or mention["notnull"] != 1
|
|
524
|
+
or mention["dflt_value"] != "1"
|
|
525
|
+
):
|
|
526
|
+
raise RuntimeError("current Binding completion mention column has invalid shape")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _require_database_integrity(connection: sqlite3.Connection) -> None:
|
|
530
|
+
integrity_rows = connection.execute("PRAGMA integrity_check").fetchall()
|
|
531
|
+
if [row[0] for row in integrity_rows] != ["ok"]:
|
|
532
|
+
raise RuntimeError("Channel database integrity check failed")
|
|
533
|
+
if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
|
534
|
+
raise RuntimeError("Channel database foreign-key check failed")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class BindingStore:
|
|
538
|
+
"""Keep only state that native Codex cannot own.
|
|
539
|
+
|
|
540
|
+
This object also implements the Channel SDK 1.x ``DedupStore`` protocol:
|
|
541
|
+
``seen(key)`` and ``mark(key, ttl_seconds)``.
|
|
542
|
+
|
|
543
|
+
A file-backed Store owns a WAL writer plus one query-only Admin reader.
|
|
544
|
+
``:memory:`` is the explicit compatibility/test mode: Admin reads run on
|
|
545
|
+
the same connection under the writer lock, never against a second empty
|
|
546
|
+
in-memory database.
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
def __init__(
|
|
550
|
+
self,
|
|
551
|
+
path: str | Path = ":memory:",
|
|
552
|
+
*,
|
|
553
|
+
id_factory: Callable[[], str] | None = None,
|
|
554
|
+
wall_clock: Callable[[], float] = time.time,
|
|
555
|
+
) -> None:
|
|
556
|
+
self._path = path
|
|
557
|
+
self._path_string = str(path)
|
|
558
|
+
self._is_memory = self._path_string == ":memory:"
|
|
559
|
+
if not self._is_memory and Path(path).exists():
|
|
560
|
+
# Reject unsupported data before a writer can recover/checkpoint a
|
|
561
|
+
# previous process's WAL, even while closing after an open failure.
|
|
562
|
+
validate_channel_database(path, allow_empty=True)
|
|
563
|
+
self._id_factory = id_factory or (lambda: str(uuid.uuid4()))
|
|
564
|
+
self._wall_clock = wall_clock
|
|
565
|
+
self._lock = threading.RLock()
|
|
566
|
+
self._project_delete_intents: dict[str, ProjectDeleteSnapshot] = {}
|
|
567
|
+
self._query_state_lock = threading.Lock()
|
|
568
|
+
self._query_futures: set[concurrent.futures.Future[object]] = set()
|
|
569
|
+
self._query_closing = False
|
|
570
|
+
self._closed = False
|
|
571
|
+
self._query_connection: sqlite3.Connection | None = None
|
|
572
|
+
self._query_executor = concurrent.futures.ThreadPoolExecutor(
|
|
573
|
+
max_workers=1,
|
|
574
|
+
thread_name_prefix="netizen-binding-query",
|
|
575
|
+
)
|
|
576
|
+
if not self._is_memory:
|
|
577
|
+
db_path = Path(path)
|
|
578
|
+
db_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
579
|
+
self._connection = sqlite3.connect(
|
|
580
|
+
self._path_string,
|
|
581
|
+
isolation_level=None,
|
|
582
|
+
check_same_thread=False,
|
|
583
|
+
)
|
|
584
|
+
self._connection.row_factory = sqlite3.Row
|
|
585
|
+
self.schedules = ScheduleStore(self)
|
|
586
|
+
self.defaults = DefaultsStore(self)
|
|
587
|
+
try:
|
|
588
|
+
self._initialize()
|
|
589
|
+
if not self._is_memory:
|
|
590
|
+
self._query_connection = self._query_executor.submit(
|
|
591
|
+
self._open_query_connection
|
|
592
|
+
).result()
|
|
593
|
+
except BaseException:
|
|
594
|
+
self._connection.close()
|
|
595
|
+
self._query_executor.shutdown(wait=True, cancel_futures=True)
|
|
596
|
+
raise
|
|
597
|
+
if not self._is_memory:
|
|
598
|
+
os.chmod(Path(path), 0o600)
|
|
599
|
+
|
|
600
|
+
def _initialize(self) -> None:
|
|
601
|
+
with self._lock:
|
|
602
|
+
tables = {
|
|
603
|
+
row["name"] for row in self._connection.execute(
|
|
604
|
+
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
if tables:
|
|
608
|
+
if "schema_version" not in tables:
|
|
609
|
+
raise RuntimeError("Channel database is missing its schema version; existing data was not changed")
|
|
610
|
+
_require_current_schema(self._connection)
|
|
611
|
+
rows = True
|
|
612
|
+
else:
|
|
613
|
+
self._connection.execute("CREATE TABLE schema_version (version INTEGER NOT NULL)")
|
|
614
|
+
rows = False
|
|
615
|
+
if not self._is_memory:
|
|
616
|
+
journal_mode = self._connection.execute(
|
|
617
|
+
"PRAGMA journal_mode = WAL"
|
|
618
|
+
).fetchone()[0]
|
|
619
|
+
if str(journal_mode).lower() != "wal":
|
|
620
|
+
raise RuntimeError("could not enable WAL for Channel database")
|
|
621
|
+
self._connection.execute("PRAGMA synchronous = FULL")
|
|
622
|
+
self._connection.execute("PRAGMA busy_timeout = 250")
|
|
623
|
+
self._connection.executescript(
|
|
624
|
+
"""
|
|
625
|
+
PRAGMA foreign_keys = ON;
|
|
626
|
+
CREATE TABLE IF NOT EXISTS scopes (
|
|
627
|
+
scope_key TEXT PRIMARY KEY,
|
|
628
|
+
app_id TEXT NOT NULL,
|
|
629
|
+
chat_id TEXT NOT NULL,
|
|
630
|
+
kind TEXT NOT NULL,
|
|
631
|
+
topic_id TEXT,
|
|
632
|
+
active_binding_id TEXT,
|
|
633
|
+
updated_at TEXT NOT NULL
|
|
634
|
+
);
|
|
635
|
+
CREATE TABLE IF NOT EXISTS bindings (
|
|
636
|
+
binding_id TEXT PRIMARY KEY,
|
|
637
|
+
scope_key TEXT NOT NULL REFERENCES scopes(scope_key),
|
|
638
|
+
project_alias TEXT NOT NULL,
|
|
639
|
+
native_thread_id TEXT UNIQUE,
|
|
640
|
+
model_id TEXT,
|
|
641
|
+
effort_id TEXT,
|
|
642
|
+
service_tier_id TEXT,
|
|
643
|
+
settings_revision INTEGER NOT NULL DEFAULT 1
|
|
644
|
+
CHECK(settings_revision >= 1),
|
|
645
|
+
message_context_mode TEXT NOT NULL DEFAULT 'current-only'
|
|
646
|
+
CHECK(
|
|
647
|
+
message_context_mode IN ('current-only', 'catch-up')
|
|
648
|
+
),
|
|
649
|
+
context_anchor_message_id TEXT,
|
|
650
|
+
context_anchor_create_time_ms INTEGER,
|
|
651
|
+
context_revision INTEGER NOT NULL DEFAULT 1
|
|
652
|
+
CHECK(
|
|
653
|
+
typeof(context_revision) = 'integer'
|
|
654
|
+
AND context_revision >= 1
|
|
655
|
+
),
|
|
656
|
+
task_reactions_enabled INTEGER NOT NULL DEFAULT 0
|
|
657
|
+
CHECK(
|
|
658
|
+
typeof(task_reactions_enabled) = 'integer'
|
|
659
|
+
AND task_reactions_enabled IN (0, 1)
|
|
660
|
+
),
|
|
661
|
+
progress_card_enabled INTEGER NOT NULL DEFAULT 0
|
|
662
|
+
CHECK(
|
|
663
|
+
typeof(progress_card_enabled) = 'integer'
|
|
664
|
+
AND progress_card_enabled IN (0, 1)
|
|
665
|
+
),
|
|
666
|
+
completion_mention_enabled INTEGER NOT NULL DEFAULT 1
|
|
667
|
+
CHECK(
|
|
668
|
+
typeof(completion_mention_enabled) = 'integer'
|
|
669
|
+
AND completion_mention_enabled IN (0, 1)
|
|
670
|
+
),
|
|
671
|
+
feedback_revision INTEGER NOT NULL DEFAULT 1
|
|
672
|
+
CHECK(
|
|
673
|
+
typeof(feedback_revision) = 'integer'
|
|
674
|
+
AND feedback_revision >= 1
|
|
675
|
+
),
|
|
676
|
+
creator_id TEXT NOT NULL,
|
|
677
|
+
created_at TEXT NOT NULL,
|
|
678
|
+
activated_at TEXT NOT NULL,
|
|
679
|
+
ever_activated INTEGER NOT NULL DEFAULT 1
|
|
680
|
+
CHECK(ever_activated IN (0, 1)),
|
|
681
|
+
CHECK(
|
|
682
|
+
(
|
|
683
|
+
model_id IS NULL
|
|
684
|
+
AND effort_id IS NULL
|
|
685
|
+
AND service_tier_id IS NULL
|
|
686
|
+
) OR (
|
|
687
|
+
model_id IS NOT NULL
|
|
688
|
+
AND effort_id IS NOT NULL
|
|
689
|
+
AND service_tier_id IS NOT NULL
|
|
690
|
+
)
|
|
691
|
+
),
|
|
692
|
+
CHECK(
|
|
693
|
+
(
|
|
694
|
+
message_context_mode = 'current-only'
|
|
695
|
+
AND context_anchor_message_id IS NULL
|
|
696
|
+
AND context_anchor_create_time_ms IS NULL
|
|
697
|
+
) OR (
|
|
698
|
+
message_context_mode = 'catch-up'
|
|
699
|
+
AND context_anchor_message_id IS NOT NULL
|
|
700
|
+
AND length(context_anchor_message_id) > 0
|
|
701
|
+
AND context_anchor_create_time_ms IS NOT NULL
|
|
702
|
+
AND typeof(context_anchor_create_time_ms) = 'integer'
|
|
703
|
+
AND context_anchor_create_time_ms > 0
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
);
|
|
707
|
+
CREATE INDEX IF NOT EXISTS bindings_by_scope
|
|
708
|
+
ON bindings(scope_key, activated_at DESC, created_at DESC);
|
|
709
|
+
CREATE TABLE IF NOT EXISTS dedup_keys (
|
|
710
|
+
dedup_key TEXT PRIMARY KEY,
|
|
711
|
+
expires_at REAL NOT NULL
|
|
712
|
+
);
|
|
713
|
+
CREATE TABLE IF NOT EXISTS side_topics (
|
|
714
|
+
side_id TEXT PRIMARY KEY,
|
|
715
|
+
app_id TEXT NOT NULL,
|
|
716
|
+
chat_id TEXT NOT NULL,
|
|
717
|
+
topic_id TEXT,
|
|
718
|
+
root_message_id TEXT,
|
|
719
|
+
source_message_id TEXT NOT NULL,
|
|
720
|
+
parent_binding_id TEXT NOT NULL,
|
|
721
|
+
creator_id TEXT NOT NULL,
|
|
722
|
+
requires_mention INTEGER NOT NULL
|
|
723
|
+
CHECK(requires_mention IN (0, 1)),
|
|
724
|
+
state TEXT NOT NULL
|
|
725
|
+
CHECK(state IN ('creating', 'open', 'closed', 'expired', 'failed')),
|
|
726
|
+
created_at TEXT NOT NULL,
|
|
727
|
+
updated_at TEXT NOT NULL,
|
|
728
|
+
UNIQUE(app_id, source_message_id)
|
|
729
|
+
);
|
|
730
|
+
CREATE UNIQUE INDEX IF NOT EXISTS side_topics_by_topic
|
|
731
|
+
ON side_topics(app_id, chat_id, topic_id)
|
|
732
|
+
WHERE topic_id IS NOT NULL;
|
|
733
|
+
CREATE UNIQUE INDEX IF NOT EXISTS side_topics_by_root
|
|
734
|
+
ON side_topics(app_id, chat_id, root_message_id)
|
|
735
|
+
WHERE root_message_id IS NOT NULL;
|
|
736
|
+
"""
|
|
737
|
+
)
|
|
738
|
+
with self._transaction():
|
|
739
|
+
self._connection.execute(
|
|
740
|
+
"""
|
|
741
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
742
|
+
alias TEXT PRIMARY KEY,
|
|
743
|
+
cwd TEXT NOT NULL,
|
|
744
|
+
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
|
|
745
|
+
deleted INTEGER NOT NULL DEFAULT 0
|
|
746
|
+
CHECK(typeof(deleted) = 'integer' AND deleted IN (0, 1)),
|
|
747
|
+
revision INTEGER NOT NULL CHECK(revision >= 1),
|
|
748
|
+
created_at TEXT NOT NULL,
|
|
749
|
+
updated_at TEXT NOT NULL
|
|
750
|
+
)
|
|
751
|
+
"""
|
|
752
|
+
)
|
|
753
|
+
self._connection.execute(
|
|
754
|
+
"""
|
|
755
|
+
CREATE TRIGGER IF NOT EXISTS bindings_turn_settings_insert
|
|
756
|
+
BEFORE INSERT ON bindings
|
|
757
|
+
WHEN NOT (
|
|
758
|
+
(
|
|
759
|
+
NEW.model_id IS NULL
|
|
760
|
+
AND NEW.effort_id IS NULL
|
|
761
|
+
AND NEW.service_tier_id IS NULL
|
|
762
|
+
) OR (
|
|
763
|
+
NEW.model_id IS NOT NULL
|
|
764
|
+
AND NEW.effort_id IS NOT NULL
|
|
765
|
+
AND NEW.service_tier_id IS NOT NULL
|
|
766
|
+
AND length(NEW.model_id) > 0
|
|
767
|
+
AND length(NEW.effort_id) > 0
|
|
768
|
+
AND length(NEW.service_tier_id) > 0
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
BEGIN
|
|
772
|
+
SELECT RAISE(
|
|
773
|
+
ABORT,
|
|
774
|
+
'Binding Turn settings must be all NULL or all set'
|
|
775
|
+
);
|
|
776
|
+
END
|
|
777
|
+
"""
|
|
778
|
+
)
|
|
779
|
+
self._connection.execute(
|
|
780
|
+
"""
|
|
781
|
+
CREATE TRIGGER IF NOT EXISTS bindings_turn_settings_update
|
|
782
|
+
BEFORE UPDATE OF model_id, effort_id, service_tier_id ON bindings
|
|
783
|
+
WHEN NOT (
|
|
784
|
+
(
|
|
785
|
+
NEW.model_id IS NULL
|
|
786
|
+
AND NEW.effort_id IS NULL
|
|
787
|
+
AND NEW.service_tier_id IS NULL
|
|
788
|
+
) OR (
|
|
789
|
+
NEW.model_id IS NOT NULL
|
|
790
|
+
AND NEW.effort_id IS NOT NULL
|
|
791
|
+
AND NEW.service_tier_id IS NOT NULL
|
|
792
|
+
AND length(NEW.model_id) > 0
|
|
793
|
+
AND length(NEW.effort_id) > 0
|
|
794
|
+
AND length(NEW.service_tier_id) > 0
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
BEGIN
|
|
798
|
+
SELECT RAISE(
|
|
799
|
+
ABORT,
|
|
800
|
+
'Binding Turn settings must be all NULL or all set'
|
|
801
|
+
);
|
|
802
|
+
END
|
|
803
|
+
"""
|
|
804
|
+
)
|
|
805
|
+
for statement in _CONTEXT_INTEGRITY_TRIGGERS:
|
|
806
|
+
self._connection.execute(statement)
|
|
807
|
+
self._connection.execute(
|
|
808
|
+
"""
|
|
809
|
+
CREATE TRIGGER IF NOT EXISTS scopes_activate_binding
|
|
810
|
+
AFTER UPDATE OF active_binding_id ON scopes
|
|
811
|
+
WHEN NEW.active_binding_id IS NOT NULL
|
|
812
|
+
BEGIN
|
|
813
|
+
UPDATE bindings
|
|
814
|
+
SET ever_activated = 1
|
|
815
|
+
WHERE binding_id = NEW.active_binding_id
|
|
816
|
+
AND scope_key = NEW.scope_key;
|
|
817
|
+
END
|
|
818
|
+
"""
|
|
819
|
+
)
|
|
820
|
+
for statement in _COMPATIBLE_INDEXES:
|
|
821
|
+
self._connection.execute(statement)
|
|
822
|
+
if rows:
|
|
823
|
+
require_schema(self._connection)
|
|
824
|
+
require_defaults_schema(self._connection)
|
|
825
|
+
else:
|
|
826
|
+
create_schema(self._connection)
|
|
827
|
+
create_defaults_schema(self._connection)
|
|
828
|
+
if not rows:
|
|
829
|
+
self._connection.execute(
|
|
830
|
+
"INSERT INTO schema_version(version) VALUES (?)",
|
|
831
|
+
(SCHEMA_VERSION,),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
def _open_query_connection(self) -> sqlite3.Connection:
|
|
835
|
+
connection = sqlite3.connect(
|
|
836
|
+
self._path_string,
|
|
837
|
+
isolation_level=None,
|
|
838
|
+
check_same_thread=False,
|
|
839
|
+
)
|
|
840
|
+
connection.row_factory = sqlite3.Row
|
|
841
|
+
try:
|
|
842
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
843
|
+
connection.execute("PRAGMA busy_timeout = 0")
|
|
844
|
+
connection.execute("PRAGMA query_only = ON")
|
|
845
|
+
except BaseException:
|
|
846
|
+
connection.close()
|
|
847
|
+
raise
|
|
848
|
+
return connection
|
|
849
|
+
|
|
850
|
+
def expire_live_side_topics(self) -> tuple[SideTopicRecord, ...]:
|
|
851
|
+
"""Expire non-terminal Side routes at the explicit service boundary.
|
|
852
|
+
|
|
853
|
+
Side Sessions and their ephemeral native Thread IDs are intentionally
|
|
854
|
+
not persisted, so a freshly started service cannot resume either a
|
|
855
|
+
``creating`` or ``open`` route. Merely opening the Store is read-only
|
|
856
|
+
with respect to this lifecycle decision; ``ServiceCore.start`` owns it.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
with self._transaction():
|
|
860
|
+
rows = self._connection.execute(
|
|
861
|
+
_SIDE_TOPIC_SELECT
|
|
862
|
+
+ " WHERE state IN ('creating', 'open') ORDER BY created_at, side_id"
|
|
863
|
+
).fetchall()
|
|
864
|
+
if rows:
|
|
865
|
+
self._connection.execute(
|
|
866
|
+
"""
|
|
867
|
+
UPDATE side_topics
|
|
868
|
+
SET state = 'expired', updated_at = ?
|
|
869
|
+
WHERE state IN ('creating', 'open')
|
|
870
|
+
""",
|
|
871
|
+
(_now(),),
|
|
872
|
+
)
|
|
873
|
+
return tuple(self.get_side_topic(row["side_id"]) for row in rows)
|
|
874
|
+
|
|
875
|
+
def create_binding(
|
|
876
|
+
self,
|
|
877
|
+
*,
|
|
878
|
+
scope: FeishuScope,
|
|
879
|
+
project_alias: str,
|
|
880
|
+
creator_id: str,
|
|
881
|
+
turn_settings: BindingTurnSettings | None = None,
|
|
882
|
+
task_feedback: BindingTaskFeedback = BindingTaskFeedback(),
|
|
883
|
+
message_context_mode: MentionContextMode = MentionContextMode.CURRENT_ONLY,
|
|
884
|
+
context_anchor: MessageContextAnchor | None = None,
|
|
885
|
+
) -> ThreadBinding:
|
|
886
|
+
"""Compatibility entry point for older callers.
|
|
887
|
+
|
|
888
|
+
Production ingress should use :meth:`create_channel_binding`, whose
|
|
889
|
+
Project check is unconditional. Existing direct callers
|
|
890
|
+
that have an entirely empty Project registry retain the historical
|
|
891
|
+
construction seam; once any Project exists, this method applies the
|
|
892
|
+
same enabled-Project requirement as production.
|
|
893
|
+
"""
|
|
894
|
+
|
|
895
|
+
return self._create_binding(
|
|
896
|
+
scope=scope,
|
|
897
|
+
project_alias=project_alias,
|
|
898
|
+
creator_id=creator_id,
|
|
899
|
+
turn_settings=turn_settings,
|
|
900
|
+
task_feedback=task_feedback,
|
|
901
|
+
message_context_mode=message_context_mode,
|
|
902
|
+
context_anchor=context_anchor,
|
|
903
|
+
expected_project_revision=None,
|
|
904
|
+
activate=True,
|
|
905
|
+
allow_scope_insert=True,
|
|
906
|
+
allow_empty_project_registry=True,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def create_channel_binding(
|
|
910
|
+
self,
|
|
911
|
+
*,
|
|
912
|
+
scope: FeishuScope,
|
|
913
|
+
project_alias: str,
|
|
914
|
+
creator_id: str,
|
|
915
|
+
expected_project_revision: int | None = None,
|
|
916
|
+
turn_settings: BindingTurnSettings | None = None,
|
|
917
|
+
task_feedback: BindingTaskFeedback = BindingTaskFeedback(),
|
|
918
|
+
message_context_mode: MentionContextMode = MentionContextMode.CURRENT_ONLY,
|
|
919
|
+
context_anchor: MessageContextAnchor | None = None,
|
|
920
|
+
) -> ThreadBinding:
|
|
921
|
+
"""Atomically validate Project state, upsert exact Scope, and activate."""
|
|
922
|
+
|
|
923
|
+
if expected_project_revision is not None and expected_project_revision < 1:
|
|
924
|
+
raise ValueError("expected Project revision must be positive")
|
|
925
|
+
return self._create_binding(
|
|
926
|
+
scope=scope,
|
|
927
|
+
project_alias=project_alias,
|
|
928
|
+
creator_id=creator_id,
|
|
929
|
+
turn_settings=turn_settings,
|
|
930
|
+
task_feedback=task_feedback,
|
|
931
|
+
message_context_mode=message_context_mode,
|
|
932
|
+
context_anchor=context_anchor,
|
|
933
|
+
expected_project_revision=expected_project_revision,
|
|
934
|
+
activate=True,
|
|
935
|
+
allow_scope_insert=True,
|
|
936
|
+
allow_empty_project_registry=False,
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
def create_admin_binding(
|
|
940
|
+
self,
|
|
941
|
+
*,
|
|
942
|
+
scope: FeishuScope,
|
|
943
|
+
project_alias: str,
|
|
944
|
+
expected_project_revision: int,
|
|
945
|
+
activate: bool = False,
|
|
946
|
+
creator_id: str = "admin:web",
|
|
947
|
+
turn_settings: BindingTurnSettings | None = None,
|
|
948
|
+
task_feedback: BindingTaskFeedback = BindingTaskFeedback(),
|
|
949
|
+
) -> ThreadBinding:
|
|
950
|
+
"""Atomically create a Lazy Binding in an exact existing Scope."""
|
|
951
|
+
|
|
952
|
+
if expected_project_revision < 1:
|
|
953
|
+
raise ValueError("expected Project revision must be positive")
|
|
954
|
+
return self._create_binding(
|
|
955
|
+
scope=scope,
|
|
956
|
+
project_alias=project_alias,
|
|
957
|
+
creator_id=creator_id,
|
|
958
|
+
turn_settings=turn_settings,
|
|
959
|
+
task_feedback=task_feedback,
|
|
960
|
+
message_context_mode=MentionContextMode.CURRENT_ONLY,
|
|
961
|
+
context_anchor=None,
|
|
962
|
+
expected_project_revision=expected_project_revision,
|
|
963
|
+
activate=activate,
|
|
964
|
+
allow_scope_insert=False,
|
|
965
|
+
allow_empty_project_registry=False,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
def _create_binding(
|
|
969
|
+
self,
|
|
970
|
+
*,
|
|
971
|
+
scope: FeishuScope,
|
|
972
|
+
project_alias: str,
|
|
973
|
+
creator_id: str,
|
|
974
|
+
turn_settings: BindingTurnSettings | None,
|
|
975
|
+
task_feedback: BindingTaskFeedback,
|
|
976
|
+
message_context_mode: MentionContextMode,
|
|
977
|
+
context_anchor: MessageContextAnchor | None,
|
|
978
|
+
expected_project_revision: int | None,
|
|
979
|
+
activate: bool,
|
|
980
|
+
allow_scope_insert: bool,
|
|
981
|
+
allow_empty_project_registry: bool,
|
|
982
|
+
scheduled_run_id: str | None = None,
|
|
983
|
+
) -> ThreadBinding:
|
|
984
|
+
if not project_alias or not creator_id:
|
|
985
|
+
raise ValueError("Binding Project and creator must not be empty")
|
|
986
|
+
_validate_context_state(
|
|
987
|
+
scope_kind=scope.kind,
|
|
988
|
+
mode=message_context_mode,
|
|
989
|
+
anchor=context_anchor,
|
|
990
|
+
)
|
|
991
|
+
binding_id = self._id_factory()
|
|
992
|
+
now = _now()
|
|
993
|
+
settings_values = _settings_values(turn_settings)
|
|
994
|
+
feedback_values = _feedback_values(task_feedback)
|
|
995
|
+
context_values = _context_values(context_anchor)
|
|
996
|
+
with self._transaction():
|
|
997
|
+
if scheduled_run_id is not None:
|
|
998
|
+
run = self.schedules.get_run(scheduled_run_id)
|
|
999
|
+
if (
|
|
1000
|
+
run.target_kind != "new_topic"
|
|
1001
|
+
or run.barrier != "held" or run.phase != "publishing_topic"
|
|
1002
|
+
or run.binding_id is not None
|
|
1003
|
+
or run.project_alias != project_alias
|
|
1004
|
+
or scope.kind is not ScopeKind.TOPIC
|
|
1005
|
+
or (run.app_id, run.chat_id, run.topic_id)
|
|
1006
|
+
!= (scope.app_id, scope.chat_id, scope.topic_id)
|
|
1007
|
+
or not run.root_message_id or not run.origin_message_id
|
|
1008
|
+
):
|
|
1009
|
+
raise ScheduleConflict("定时话题交接身份不匹配。")
|
|
1010
|
+
side_route = self._connection.execute(
|
|
1011
|
+
"SELECT 1 FROM side_topics WHERE app_id=? AND chat_id=? "
|
|
1012
|
+
"AND (topic_id=? OR root_message_id=?) LIMIT 1",
|
|
1013
|
+
(scope.app_id, scope.chat_id, scope.topic_id, run.root_message_id),
|
|
1014
|
+
).fetchone()
|
|
1015
|
+
if side_route is not None:
|
|
1016
|
+
raise ScheduleConflict("定时话题与已有 Side 路由冲突,本次未创建普通会话。")
|
|
1017
|
+
scope_row = self._connection.execute(
|
|
1018
|
+
_SCOPE_SELECT + " WHERE scope_key = ?",
|
|
1019
|
+
(scope.key,),
|
|
1020
|
+
).fetchone()
|
|
1021
|
+
if scope_row is None:
|
|
1022
|
+
if not allow_scope_insert:
|
|
1023
|
+
raise ScopeNotFound(scope.key)
|
|
1024
|
+
self._connection.execute(
|
|
1025
|
+
"""
|
|
1026
|
+
INSERT INTO scopes(
|
|
1027
|
+
scope_key, app_id, chat_id, kind, topic_id,
|
|
1028
|
+
active_binding_id, updated_at
|
|
1029
|
+
) VALUES (?, ?, ?, ?, ?, NULL, ?)
|
|
1030
|
+
""",
|
|
1031
|
+
(
|
|
1032
|
+
scope.key,
|
|
1033
|
+
scope.app_id,
|
|
1034
|
+
scope.chat_id,
|
|
1035
|
+
scope.kind.value,
|
|
1036
|
+
scope.topic_id,
|
|
1037
|
+
now,
|
|
1038
|
+
),
|
|
1039
|
+
)
|
|
1040
|
+
else:
|
|
1041
|
+
_require_exact_scope(scope_row, scope)
|
|
1042
|
+
if scheduled_run_id is not None and (
|
|
1043
|
+
scope_row["active_binding_id"] is not None
|
|
1044
|
+
or self._connection.execute(
|
|
1045
|
+
"SELECT 1 FROM bindings WHERE scope_key=? LIMIT 1", (scope.key,)
|
|
1046
|
+
).fetchone() is not None
|
|
1047
|
+
):
|
|
1048
|
+
raise ScopeConflict("话题已经建立会话,请继续使用该会话。")
|
|
1049
|
+
if allow_scope_insert:
|
|
1050
|
+
self._connection.execute(
|
|
1051
|
+
"UPDATE scopes SET updated_at = ? WHERE scope_key = ?",
|
|
1052
|
+
(now, scope.key),
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
project_row = self._connection.execute(
|
|
1056
|
+
_PROJECT_SELECT + " WHERE alias = ? AND deleted = 0",
|
|
1057
|
+
(project_alias,),
|
|
1058
|
+
).fetchone()
|
|
1059
|
+
self.require_project_not_deleting(project_alias)
|
|
1060
|
+
if project_row is None and allow_empty_project_registry:
|
|
1061
|
+
any_project = self._connection.execute(
|
|
1062
|
+
"SELECT 1 FROM projects LIMIT 1"
|
|
1063
|
+
).fetchone()
|
|
1064
|
+
if any_project is not None:
|
|
1065
|
+
raise ProjectNotFound(project_alias)
|
|
1066
|
+
elif project_row is None:
|
|
1067
|
+
raise ProjectNotFound(project_alias)
|
|
1068
|
+
if project_row is not None:
|
|
1069
|
+
if not bool(project_row["enabled"]):
|
|
1070
|
+
raise ProjectDisabled(project_alias)
|
|
1071
|
+
if (
|
|
1072
|
+
expected_project_revision is not None
|
|
1073
|
+
and project_row["revision"] != expected_project_revision
|
|
1074
|
+
):
|
|
1075
|
+
raise ProjectRevisionConflict(project_alias)
|
|
1076
|
+
|
|
1077
|
+
self._connection.execute(
|
|
1078
|
+
"""
|
|
1079
|
+
INSERT INTO bindings(
|
|
1080
|
+
binding_id, scope_key, project_alias, native_thread_id,
|
|
1081
|
+
model_id, effort_id, service_tier_id, settings_revision,
|
|
1082
|
+
message_context_mode, context_anchor_message_id,
|
|
1083
|
+
context_anchor_create_time_ms, context_revision,
|
|
1084
|
+
task_reactions_enabled, progress_card_enabled,
|
|
1085
|
+
completion_mention_enabled,
|
|
1086
|
+
feedback_revision,
|
|
1087
|
+
creator_id, created_at, activated_at, ever_activated
|
|
1088
|
+
) VALUES (
|
|
1089
|
+
?, ?, ?, NULL, ?, ?, ?, 1, ?, ?, ?, 1, ?, ?, ?, 1, ?, ?, ?, ?
|
|
1090
|
+
)
|
|
1091
|
+
""",
|
|
1092
|
+
(
|
|
1093
|
+
binding_id,
|
|
1094
|
+
scope.key,
|
|
1095
|
+
project_alias,
|
|
1096
|
+
*settings_values,
|
|
1097
|
+
message_context_mode.value,
|
|
1098
|
+
*context_values,
|
|
1099
|
+
*feedback_values,
|
|
1100
|
+
creator_id,
|
|
1101
|
+
now,
|
|
1102
|
+
now,
|
|
1103
|
+
int(activate),
|
|
1104
|
+
),
|
|
1105
|
+
)
|
|
1106
|
+
if activate:
|
|
1107
|
+
self._connection.execute(
|
|
1108
|
+
"""
|
|
1109
|
+
UPDATE scopes
|
|
1110
|
+
SET active_binding_id = ?, updated_at = ?
|
|
1111
|
+
WHERE scope_key = ?
|
|
1112
|
+
""",
|
|
1113
|
+
(binding_id, now, scope.key),
|
|
1114
|
+
)
|
|
1115
|
+
if scheduled_run_id is not None:
|
|
1116
|
+
self.schedules._set_run(
|
|
1117
|
+
scheduled_run_id, phase="binding_ready", binding_id=binding_id,
|
|
1118
|
+
)
|
|
1119
|
+
return self.get(binding_id)
|
|
1120
|
+
|
|
1121
|
+
def create_scheduled_binding(
|
|
1122
|
+
self, *, run_id: str, scope: FeishuScope,
|
|
1123
|
+
creator_id: str = "scheduled_plan",
|
|
1124
|
+
session_settings: SessionSettings = SessionSettings(),
|
|
1125
|
+
context_anchor: MessageContextAnchor | None = None,
|
|
1126
|
+
) -> ThreadBinding:
|
|
1127
|
+
if not isinstance(session_settings, SessionSettings):
|
|
1128
|
+
raise ValueError("scheduled session_settings must be SessionSettings")
|
|
1129
|
+
run = self.schedules.get_run(run_id)
|
|
1130
|
+
return self._create_binding(
|
|
1131
|
+
scope=scope, project_alias=run.project_alias, creator_id=creator_id,
|
|
1132
|
+
turn_settings=session_settings.turn_settings,
|
|
1133
|
+
task_feedback=session_settings.task_feedback,
|
|
1134
|
+
message_context_mode=session_settings.message_context_mode,
|
|
1135
|
+
context_anchor=context_anchor, expected_project_revision=None, activate=True,
|
|
1136
|
+
allow_scope_insert=True, allow_empty_project_registry=False,
|
|
1137
|
+
scheduled_run_id=run_id,
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
def find_by_native_thread_id(self, native_thread_id: str) -> ThreadBinding | None:
|
|
1141
|
+
with self._lock:
|
|
1142
|
+
row = self._connection.execute(
|
|
1143
|
+
_BINDING_SELECT + " WHERE b.native_thread_id=?", (native_thread_id,),
|
|
1144
|
+
).fetchone()
|
|
1145
|
+
return _binding(row) if row is not None else None
|
|
1146
|
+
|
|
1147
|
+
def scheduled_initial_reservation(self, binding_id: str) -> str | None:
|
|
1148
|
+
with self._lock:
|
|
1149
|
+
row = self._connection.execute(
|
|
1150
|
+
"SELECT run_id FROM schedule_runs WHERE binding_id=? "
|
|
1151
|
+
"AND target_kind='new_topic' AND phase IN ('binding_ready','starting_turn') AND barrier != 'released'",
|
|
1152
|
+
(binding_id,),
|
|
1153
|
+
).fetchone()
|
|
1154
|
+
return row["run_id"] if row is not None else None
|
|
1155
|
+
|
|
1156
|
+
def require_scheduled_initial(self, run_id: str, binding_id: str) -> Run:
|
|
1157
|
+
with self._lock:
|
|
1158
|
+
run = self.schedules.get_run(run_id)
|
|
1159
|
+
binding = self.get(binding_id)
|
|
1160
|
+
scope = self.get_scope(binding.scope_key)
|
|
1161
|
+
self.require_project_not_deleting(run.project_alias)
|
|
1162
|
+
if not self.get_project(run.project_alias).enabled:
|
|
1163
|
+
raise ProjectDisabled(run.project_alias)
|
|
1164
|
+
if (
|
|
1165
|
+
run.target_kind != "new_topic" or run.binding_id != binding_id or run.barrier != "held"
|
|
1166
|
+
or run.phase != "binding_ready" or run.initial_turn_id is not None
|
|
1167
|
+
or binding.native_thread_id is not None or not binding.active
|
|
1168
|
+
or binding.project_alias != run.project_alias
|
|
1169
|
+
or (scope.app_id, scope.chat_id, scope.topic_id)
|
|
1170
|
+
!= (run.app_id, run.chat_id, run.topic_id)
|
|
1171
|
+
):
|
|
1172
|
+
raise ScheduleConflict("定时初轮只能启动认领的初始会话。")
|
|
1173
|
+
return run
|
|
1174
|
+
|
|
1175
|
+
def begin_scheduled_initial(self, run_id: str, binding_id: str) -> Run:
|
|
1176
|
+
with self._transaction():
|
|
1177
|
+
self.require_scheduled_initial(run_id, binding_id)
|
|
1178
|
+
return self.schedules._set_run(run_id, phase="starting_turn")
|
|
1179
|
+
|
|
1180
|
+
def mark_scheduled_turn_started(self, run_id: str, binding_id: str, turn_id: str) -> Run:
|
|
1181
|
+
with self._transaction():
|
|
1182
|
+
run = self.schedules.get_run(run_id)
|
|
1183
|
+
if run.target_kind != "new_topic" or run.binding_id != binding_id or run.phase not in {"starting_turn", "handed_off", "released"}:
|
|
1184
|
+
raise ScheduleConflict("定时初轮交接不匹配。")
|
|
1185
|
+
return self.schedules._set_run(run_id, initial_turn_id=turn_id, phase="handed_off")
|
|
1186
|
+
|
|
1187
|
+
def release_scheduled_initial_turn(self, binding_id: str, turn_id: str) -> None:
|
|
1188
|
+
with self._transaction():
|
|
1189
|
+
row = self._connection.execute(
|
|
1190
|
+
"SELECT run_id FROM schedule_runs WHERE binding_id=? AND barrier != 'released' "
|
|
1191
|
+
"AND target_kind='new_topic' "
|
|
1192
|
+
"AND (initial_turn_id=? OR (initial_turn_id IS NULL AND phase='starting_turn'))",
|
|
1193
|
+
(binding_id, turn_id),
|
|
1194
|
+
).fetchone()
|
|
1195
|
+
if row is not None:
|
|
1196
|
+
run = self.schedules._set_run(row["run_id"], initial_turn_id=turn_id, barrier="released")
|
|
1197
|
+
self.schedules._prune(run.plan_id)
|
|
1198
|
+
|
|
1199
|
+
def archive_binding(self, binding_id: str) -> ThreadBinding:
|
|
1200
|
+
"""Apply a proven native archive and release its scheduling barrier."""
|
|
1201
|
+
with self._transaction():
|
|
1202
|
+
binding = self.get(binding_id)
|
|
1203
|
+
self._connection.execute(
|
|
1204
|
+
"UPDATE scopes SET active_binding_id=NULL, updated_at=? "
|
|
1205
|
+
"WHERE scope_key=? AND active_binding_id=?",
|
|
1206
|
+
(_now(), binding.scope_key, binding_id),
|
|
1207
|
+
)
|
|
1208
|
+
self.schedules._release_binding(binding_id, binding_removed=False)
|
|
1209
|
+
return self.get(binding_id)
|
|
1210
|
+
|
|
1211
|
+
def set_turn_settings(
|
|
1212
|
+
self,
|
|
1213
|
+
*,
|
|
1214
|
+
binding_id: str,
|
|
1215
|
+
expected_revision: int,
|
|
1216
|
+
settings: BindingTurnSettings | None,
|
|
1217
|
+
) -> ThreadBinding:
|
|
1218
|
+
if expected_revision < 1:
|
|
1219
|
+
raise ValueError("expected settings revision must be positive")
|
|
1220
|
+
values = _settings_values(settings)
|
|
1221
|
+
with self._transaction():
|
|
1222
|
+
row = self._connection.execute(
|
|
1223
|
+
"""
|
|
1224
|
+
SELECT model_id, effort_id, service_tier_id, settings_revision
|
|
1225
|
+
FROM bindings
|
|
1226
|
+
WHERE binding_id = ?
|
|
1227
|
+
""",
|
|
1228
|
+
(binding_id,),
|
|
1229
|
+
).fetchone()
|
|
1230
|
+
if row is None:
|
|
1231
|
+
raise BindingNotFound(binding_id)
|
|
1232
|
+
if row["settings_revision"] != expected_revision:
|
|
1233
|
+
raise BindingSettingsRevisionConflict(binding_id)
|
|
1234
|
+
current = (
|
|
1235
|
+
row["model_id"],
|
|
1236
|
+
row["effort_id"],
|
|
1237
|
+
row["service_tier_id"],
|
|
1238
|
+
)
|
|
1239
|
+
if current == values:
|
|
1240
|
+
return self.get(binding_id)
|
|
1241
|
+
cursor = self._connection.execute(
|
|
1242
|
+
"""
|
|
1243
|
+
UPDATE bindings
|
|
1244
|
+
SET model_id = ?, effort_id = ?, service_tier_id = ?,
|
|
1245
|
+
settings_revision = settings_revision + 1
|
|
1246
|
+
WHERE binding_id = ? AND settings_revision = ?
|
|
1247
|
+
""",
|
|
1248
|
+
(*values, binding_id, expected_revision),
|
|
1249
|
+
)
|
|
1250
|
+
if cursor.rowcount != 1:
|
|
1251
|
+
raise BindingSettingsRevisionConflict(binding_id)
|
|
1252
|
+
return self.get(binding_id)
|
|
1253
|
+
|
|
1254
|
+
def set_configuration(
|
|
1255
|
+
self,
|
|
1256
|
+
*,
|
|
1257
|
+
binding_id: str,
|
|
1258
|
+
expected_settings_revision: int,
|
|
1259
|
+
expected_context_revision: int,
|
|
1260
|
+
expected_feedback_revision: int,
|
|
1261
|
+
settings: BindingTurnSettings | None,
|
|
1262
|
+
task_feedback: BindingTaskFeedback,
|
|
1263
|
+
message_context_mode: MentionContextMode,
|
|
1264
|
+
context_anchor: MessageContextAnchor | None,
|
|
1265
|
+
) -> ThreadBinding:
|
|
1266
|
+
"""Atomically replace Turn settings, context, and task feedback.
|
|
1267
|
+
|
|
1268
|
+
Supplying an anchor is only meaningful when changing from
|
|
1269
|
+
``current-only`` to ``catch-up``. A model-only update on an existing
|
|
1270
|
+
catch-up Binding retains its exact boundary.
|
|
1271
|
+
"""
|
|
1272
|
+
|
|
1273
|
+
if expected_settings_revision < 1:
|
|
1274
|
+
raise ValueError("expected settings revision must be positive")
|
|
1275
|
+
if expected_context_revision < 1:
|
|
1276
|
+
raise ValueError("expected context revision must be positive")
|
|
1277
|
+
if expected_feedback_revision < 1:
|
|
1278
|
+
raise ValueError("expected feedback revision must be positive")
|
|
1279
|
+
if not isinstance(message_context_mode, MentionContextMode):
|
|
1280
|
+
raise ValueError("message context mode must be a MentionContextMode")
|
|
1281
|
+
settings_values = _settings_values(settings)
|
|
1282
|
+
feedback_values = _feedback_values(task_feedback)
|
|
1283
|
+
with self._transaction():
|
|
1284
|
+
row = self._connection.execute(
|
|
1285
|
+
"""
|
|
1286
|
+
SELECT
|
|
1287
|
+
b.model_id, b.effort_id, b.service_tier_id,
|
|
1288
|
+
b.settings_revision, b.message_context_mode,
|
|
1289
|
+
b.context_anchor_message_id,
|
|
1290
|
+
b.context_anchor_create_time_ms, b.context_revision,
|
|
1291
|
+
b.task_reactions_enabled, b.progress_card_enabled,
|
|
1292
|
+
b.completion_mention_enabled,
|
|
1293
|
+
b.feedback_revision,
|
|
1294
|
+
s.kind AS scope_kind
|
|
1295
|
+
FROM bindings b
|
|
1296
|
+
JOIN scopes s ON s.scope_key = b.scope_key
|
|
1297
|
+
WHERE b.binding_id = ?
|
|
1298
|
+
""",
|
|
1299
|
+
(binding_id,),
|
|
1300
|
+
).fetchone()
|
|
1301
|
+
if row is None:
|
|
1302
|
+
raise BindingNotFound(binding_id)
|
|
1303
|
+
if row["settings_revision"] != expected_settings_revision:
|
|
1304
|
+
raise BindingSettingsRevisionConflict(binding_id)
|
|
1305
|
+
if row["context_revision"] != expected_context_revision:
|
|
1306
|
+
raise BindingContextRevisionConflict(binding_id)
|
|
1307
|
+
if row["feedback_revision"] != expected_feedback_revision:
|
|
1308
|
+
raise BindingFeedbackRevisionConflict(binding_id)
|
|
1309
|
+
|
|
1310
|
+
current_settings = (
|
|
1311
|
+
row["model_id"],
|
|
1312
|
+
row["effort_id"],
|
|
1313
|
+
row["service_tier_id"],
|
|
1314
|
+
)
|
|
1315
|
+
current_mode = _mention_context_mode(row["message_context_mode"])
|
|
1316
|
+
current_anchor = _message_context_anchor(
|
|
1317
|
+
row["context_anchor_message_id"],
|
|
1318
|
+
row["context_anchor_create_time_ms"],
|
|
1319
|
+
)
|
|
1320
|
+
scope_kind = ScopeKind(row["scope_kind"])
|
|
1321
|
+
if message_context_mode is current_mode:
|
|
1322
|
+
if context_anchor is not None:
|
|
1323
|
+
raise ValueError(
|
|
1324
|
+
"context anchor may only reset when context mode changes"
|
|
1325
|
+
)
|
|
1326
|
+
next_anchor = current_anchor
|
|
1327
|
+
context_changed = False
|
|
1328
|
+
else:
|
|
1329
|
+
next_anchor = context_anchor
|
|
1330
|
+
context_changed = True
|
|
1331
|
+
_validate_context_state(
|
|
1332
|
+
scope_kind=scope_kind,
|
|
1333
|
+
mode=message_context_mode,
|
|
1334
|
+
anchor=next_anchor,
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
settings_changed = current_settings != settings_values
|
|
1338
|
+
current_feedback = (
|
|
1339
|
+
row["task_reactions_enabled"],
|
|
1340
|
+
row["progress_card_enabled"],
|
|
1341
|
+
row["completion_mention_enabled"],
|
|
1342
|
+
)
|
|
1343
|
+
feedback_changed = current_feedback != tuple(
|
|
1344
|
+
int(value) for value in feedback_values
|
|
1345
|
+
)
|
|
1346
|
+
if (
|
|
1347
|
+
not settings_changed
|
|
1348
|
+
and not context_changed
|
|
1349
|
+
and not feedback_changed
|
|
1350
|
+
):
|
|
1351
|
+
return self.get(binding_id)
|
|
1352
|
+
anchor_values = _context_values(next_anchor)
|
|
1353
|
+
cursor = self._connection.execute(
|
|
1354
|
+
"""
|
|
1355
|
+
UPDATE bindings
|
|
1356
|
+
SET model_id = ?, effort_id = ?, service_tier_id = ?,
|
|
1357
|
+
settings_revision = settings_revision + ?,
|
|
1358
|
+
message_context_mode = ?,
|
|
1359
|
+
context_anchor_message_id = ?,
|
|
1360
|
+
context_anchor_create_time_ms = ?,
|
|
1361
|
+
context_revision = context_revision + ?,
|
|
1362
|
+
task_reactions_enabled = ?,
|
|
1363
|
+
progress_card_enabled = ?,
|
|
1364
|
+
completion_mention_enabled = ?,
|
|
1365
|
+
feedback_revision = feedback_revision + ?
|
|
1366
|
+
WHERE binding_id = ?
|
|
1367
|
+
AND settings_revision = ?
|
|
1368
|
+
AND context_revision = ?
|
|
1369
|
+
AND feedback_revision = ?
|
|
1370
|
+
""",
|
|
1371
|
+
(
|
|
1372
|
+
*settings_values,
|
|
1373
|
+
int(settings_changed),
|
|
1374
|
+
message_context_mode.value,
|
|
1375
|
+
*anchor_values,
|
|
1376
|
+
int(context_changed),
|
|
1377
|
+
*(int(value) for value in feedback_values),
|
|
1378
|
+
int(feedback_changed),
|
|
1379
|
+
binding_id,
|
|
1380
|
+
expected_settings_revision,
|
|
1381
|
+
expected_context_revision,
|
|
1382
|
+
expected_feedback_revision,
|
|
1383
|
+
),
|
|
1384
|
+
)
|
|
1385
|
+
if cursor.rowcount != 1:
|
|
1386
|
+
raise BindingConflict("Binding configuration changed concurrently")
|
|
1387
|
+
return self.get(binding_id)
|
|
1388
|
+
|
|
1389
|
+
def commit_context_anchor(
|
|
1390
|
+
self,
|
|
1391
|
+
*,
|
|
1392
|
+
binding_id: str,
|
|
1393
|
+
expected_context_revision: int,
|
|
1394
|
+
anchor: MessageContextAnchor,
|
|
1395
|
+
) -> ThreadBinding:
|
|
1396
|
+
"""Advance one catch-up boundary after native submission is accepted."""
|
|
1397
|
+
|
|
1398
|
+
if expected_context_revision < 1:
|
|
1399
|
+
raise ValueError("expected context revision must be positive")
|
|
1400
|
+
anchor_values = _context_values(anchor)
|
|
1401
|
+
with self._transaction():
|
|
1402
|
+
row = self._connection.execute(
|
|
1403
|
+
"""
|
|
1404
|
+
SELECT b.message_context_mode, b.context_revision,
|
|
1405
|
+
s.kind AS scope_kind
|
|
1406
|
+
FROM bindings b
|
|
1407
|
+
JOIN scopes s ON s.scope_key = b.scope_key
|
|
1408
|
+
WHERE b.binding_id = ?
|
|
1409
|
+
""",
|
|
1410
|
+
(binding_id,),
|
|
1411
|
+
).fetchone()
|
|
1412
|
+
if row is None:
|
|
1413
|
+
raise BindingNotFound(binding_id)
|
|
1414
|
+
if row["context_revision"] != expected_context_revision:
|
|
1415
|
+
raise BindingContextRevisionConflict(binding_id)
|
|
1416
|
+
mode = _mention_context_mode(row["message_context_mode"])
|
|
1417
|
+
if mode is not MentionContextMode.CATCH_UP:
|
|
1418
|
+
raise BindingConflict(
|
|
1419
|
+
"current-only Binding has no context anchor to commit"
|
|
1420
|
+
)
|
|
1421
|
+
_validate_context_state(
|
|
1422
|
+
scope_kind=ScopeKind(row["scope_kind"]),
|
|
1423
|
+
mode=mode,
|
|
1424
|
+
anchor=anchor,
|
|
1425
|
+
)
|
|
1426
|
+
cursor = self._connection.execute(
|
|
1427
|
+
"""
|
|
1428
|
+
UPDATE bindings
|
|
1429
|
+
SET context_anchor_message_id = ?,
|
|
1430
|
+
context_anchor_create_time_ms = ?,
|
|
1431
|
+
context_revision = context_revision + 1
|
|
1432
|
+
WHERE binding_id = ? AND context_revision = ?
|
|
1433
|
+
""",
|
|
1434
|
+
(*anchor_values, binding_id, expected_context_revision),
|
|
1435
|
+
)
|
|
1436
|
+
if cursor.rowcount != 1:
|
|
1437
|
+
raise BindingContextRevisionConflict(binding_id)
|
|
1438
|
+
return self.get(binding_id)
|
|
1439
|
+
|
|
1440
|
+
def activate(
|
|
1441
|
+
self,
|
|
1442
|
+
*,
|
|
1443
|
+
scope_key: str,
|
|
1444
|
+
binding_id: str,
|
|
1445
|
+
context_anchor: MessageContextAnchor | None = None,
|
|
1446
|
+
) -> ThreadBinding:
|
|
1447
|
+
now = _now()
|
|
1448
|
+
with self._transaction():
|
|
1449
|
+
owner = self._connection.execute(
|
|
1450
|
+
"""
|
|
1451
|
+
SELECT b.scope_key, b.message_context_mode,
|
|
1452
|
+
s.kind AS scope_kind, s.active_binding_id
|
|
1453
|
+
FROM bindings b
|
|
1454
|
+
JOIN scopes s ON s.scope_key = b.scope_key
|
|
1455
|
+
WHERE b.binding_id = ?
|
|
1456
|
+
""",
|
|
1457
|
+
(binding_id,),
|
|
1458
|
+
).fetchone()
|
|
1459
|
+
if owner is None or owner["scope_key"] != scope_key:
|
|
1460
|
+
raise BindingNotFound(binding_id)
|
|
1461
|
+
if owner["active_binding_id"] != binding_id:
|
|
1462
|
+
self.schedules._resume_binding(binding_id)
|
|
1463
|
+
mode = _mention_context_mode(owner["message_context_mode"])
|
|
1464
|
+
_validate_context_state(
|
|
1465
|
+
scope_kind=ScopeKind(owner["scope_kind"]),
|
|
1466
|
+
mode=mode,
|
|
1467
|
+
anchor=context_anchor,
|
|
1468
|
+
)
|
|
1469
|
+
if mode is MentionContextMode.CATCH_UP:
|
|
1470
|
+
anchor_values = _context_values(context_anchor)
|
|
1471
|
+
self._connection.execute(
|
|
1472
|
+
"""
|
|
1473
|
+
UPDATE bindings
|
|
1474
|
+
SET activated_at = ?, ever_activated = 1,
|
|
1475
|
+
context_anchor_message_id = ?,
|
|
1476
|
+
context_anchor_create_time_ms = ?,
|
|
1477
|
+
context_revision = context_revision + 1
|
|
1478
|
+
WHERE binding_id = ?
|
|
1479
|
+
""",
|
|
1480
|
+
(now, *anchor_values, binding_id),
|
|
1481
|
+
)
|
|
1482
|
+
else:
|
|
1483
|
+
self._connection.execute(
|
|
1484
|
+
"""
|
|
1485
|
+
UPDATE bindings
|
|
1486
|
+
SET activated_at = ?, ever_activated = 1
|
|
1487
|
+
WHERE binding_id = ?
|
|
1488
|
+
""",
|
|
1489
|
+
(now, binding_id),
|
|
1490
|
+
)
|
|
1491
|
+
self._connection.execute(
|
|
1492
|
+
"""
|
|
1493
|
+
UPDATE scopes
|
|
1494
|
+
SET active_binding_id = ?, updated_at = ?
|
|
1495
|
+
WHERE scope_key = ?
|
|
1496
|
+
""",
|
|
1497
|
+
(binding_id, now, scope_key),
|
|
1498
|
+
)
|
|
1499
|
+
return self.get(binding_id)
|
|
1500
|
+
|
|
1501
|
+
def deactivate(self, *, scope_key: str, binding_id: str) -> ThreadBinding:
|
|
1502
|
+
"""Clear the active pointer only when it still targets this Binding."""
|
|
1503
|
+
|
|
1504
|
+
with self._transaction():
|
|
1505
|
+
row = self._connection.execute(
|
|
1506
|
+
"SELECT active_binding_id FROM scopes WHERE scope_key = ?",
|
|
1507
|
+
(scope_key,),
|
|
1508
|
+
).fetchone()
|
|
1509
|
+
if row is None or row["active_binding_id"] != binding_id:
|
|
1510
|
+
raise BindingConflict("Binding is no longer active in this Scope")
|
|
1511
|
+
owner = self._connection.execute(
|
|
1512
|
+
"SELECT scope_key FROM bindings WHERE binding_id = ?",
|
|
1513
|
+
(binding_id,),
|
|
1514
|
+
).fetchone()
|
|
1515
|
+
if owner is None or owner["scope_key"] != scope_key:
|
|
1516
|
+
raise BindingNotFound(binding_id)
|
|
1517
|
+
self._connection.execute(
|
|
1518
|
+
"""
|
|
1519
|
+
UPDATE scopes
|
|
1520
|
+
SET active_binding_id = NULL, updated_at = ?
|
|
1521
|
+
WHERE scope_key = ? AND active_binding_id = ?
|
|
1522
|
+
""",
|
|
1523
|
+
(_now(), scope_key, binding_id),
|
|
1524
|
+
)
|
|
1525
|
+
return self.get(binding_id)
|
|
1526
|
+
|
|
1527
|
+
def deactivate_if_active(
|
|
1528
|
+
self,
|
|
1529
|
+
*,
|
|
1530
|
+
scope_key: str,
|
|
1531
|
+
binding_id: str,
|
|
1532
|
+
) -> ThreadBinding:
|
|
1533
|
+
"""Clear this Binding's Scope pointer when it is still current.
|
|
1534
|
+
|
|
1535
|
+
Exact management operations may archive an inactive Binding. They
|
|
1536
|
+
must still validate ownership, but must not disturb the Scope's actual
|
|
1537
|
+
current pointer merely because another Binding was the mutation target.
|
|
1538
|
+
"""
|
|
1539
|
+
|
|
1540
|
+
with self._transaction():
|
|
1541
|
+
owner = self._connection.execute(
|
|
1542
|
+
"SELECT scope_key FROM bindings WHERE binding_id = ?",
|
|
1543
|
+
(binding_id,),
|
|
1544
|
+
).fetchone()
|
|
1545
|
+
if owner is None or owner["scope_key"] != scope_key:
|
|
1546
|
+
raise BindingNotFound(binding_id)
|
|
1547
|
+
self._connection.execute(
|
|
1548
|
+
"""
|
|
1549
|
+
UPDATE scopes
|
|
1550
|
+
SET active_binding_id = NULL, updated_at = ?
|
|
1551
|
+
WHERE scope_key = ? AND active_binding_id = ?
|
|
1552
|
+
""",
|
|
1553
|
+
(_now(), scope_key, binding_id),
|
|
1554
|
+
)
|
|
1555
|
+
return self.get(binding_id)
|
|
1556
|
+
|
|
1557
|
+
def delete_binding(self, binding_id: str) -> ThreadBinding:
|
|
1558
|
+
"""Delete one Binding and clear its Scope pointer in one transaction."""
|
|
1559
|
+
|
|
1560
|
+
with self._transaction():
|
|
1561
|
+
row = self._connection.execute(
|
|
1562
|
+
_BINDING_SELECT + " WHERE b.binding_id = ?",
|
|
1563
|
+
(binding_id,),
|
|
1564
|
+
).fetchone()
|
|
1565
|
+
if row is None:
|
|
1566
|
+
raise BindingNotFound(binding_id)
|
|
1567
|
+
binding = _binding(row)
|
|
1568
|
+
self._connection.execute(
|
|
1569
|
+
"""
|
|
1570
|
+
UPDATE scopes
|
|
1571
|
+
SET active_binding_id = NULL, updated_at = ?
|
|
1572
|
+
WHERE scope_key = ? AND active_binding_id = ?
|
|
1573
|
+
""",
|
|
1574
|
+
(_now(), binding.scope_key, binding.id),
|
|
1575
|
+
)
|
|
1576
|
+
cursor = self._connection.execute(
|
|
1577
|
+
"DELETE FROM bindings WHERE binding_id = ?",
|
|
1578
|
+
(binding.id,),
|
|
1579
|
+
)
|
|
1580
|
+
if cursor.rowcount != 1:
|
|
1581
|
+
raise BindingNotFound(binding.id)
|
|
1582
|
+
self.schedules._release_binding(binding.id, binding_removed=True)
|
|
1583
|
+
return binding
|
|
1584
|
+
|
|
1585
|
+
def assign_native_thread_id(self, binding_id: str, native_thread_id: str) -> None:
|
|
1586
|
+
if not native_thread_id:
|
|
1587
|
+
raise ValueError("native_thread_id must not be empty")
|
|
1588
|
+
with self._transaction():
|
|
1589
|
+
row = self._connection.execute(
|
|
1590
|
+
"SELECT native_thread_id FROM bindings WHERE binding_id = ?",
|
|
1591
|
+
(binding_id,),
|
|
1592
|
+
).fetchone()
|
|
1593
|
+
if row is None:
|
|
1594
|
+
raise BindingNotFound(binding_id)
|
|
1595
|
+
existing = row["native_thread_id"]
|
|
1596
|
+
if existing == native_thread_id:
|
|
1597
|
+
return
|
|
1598
|
+
if existing is not None:
|
|
1599
|
+
raise BindingConflict("native Thread ID is write-once")
|
|
1600
|
+
try:
|
|
1601
|
+
self._connection.execute(
|
|
1602
|
+
"""
|
|
1603
|
+
UPDATE bindings SET native_thread_id = ?
|
|
1604
|
+
WHERE binding_id = ? AND native_thread_id IS NULL
|
|
1605
|
+
""",
|
|
1606
|
+
(native_thread_id, binding_id),
|
|
1607
|
+
)
|
|
1608
|
+
except sqlite3.IntegrityError as error:
|
|
1609
|
+
raise BindingConflict(
|
|
1610
|
+
"native Thread is already bound to another Feishu session"
|
|
1611
|
+
) from error
|
|
1612
|
+
|
|
1613
|
+
def active_binding(self, scope_key: str) -> ThreadBinding | None:
|
|
1614
|
+
with self._lock:
|
|
1615
|
+
row = self._connection.execute(
|
|
1616
|
+
_BINDING_SELECT
|
|
1617
|
+
+ " WHERE b.binding_id = s.active_binding_id AND s.scope_key = ?",
|
|
1618
|
+
(scope_key,),
|
|
1619
|
+
).fetchone()
|
|
1620
|
+
return _binding(row) if row is not None else None
|
|
1621
|
+
|
|
1622
|
+
def get_scope(self, scope_key: str) -> ScopeRecord:
|
|
1623
|
+
with self._lock:
|
|
1624
|
+
row = self._connection.execute(
|
|
1625
|
+
_SCOPE_SELECT + " WHERE scope_key = ?",
|
|
1626
|
+
(scope_key,),
|
|
1627
|
+
).fetchone()
|
|
1628
|
+
if row is None:
|
|
1629
|
+
raise ScopeNotFound(scope_key)
|
|
1630
|
+
return _scope_record(row)
|
|
1631
|
+
|
|
1632
|
+
def get(self, binding_id: str) -> ThreadBinding:
|
|
1633
|
+
with self._lock:
|
|
1634
|
+
row = self._connection.execute(
|
|
1635
|
+
_BINDING_SELECT + " WHERE b.binding_id = ?",
|
|
1636
|
+
(binding_id,),
|
|
1637
|
+
).fetchone()
|
|
1638
|
+
if row is None:
|
|
1639
|
+
raise BindingNotFound(binding_id)
|
|
1640
|
+
return _binding(row)
|
|
1641
|
+
|
|
1642
|
+
def list_bindings(self, scope_key: str) -> list[ThreadBinding]:
|
|
1643
|
+
with self._lock:
|
|
1644
|
+
rows = self._connection.execute(
|
|
1645
|
+
_BINDING_SELECT
|
|
1646
|
+
+ " WHERE b.scope_key = ? "
|
|
1647
|
+
"ORDER BY b.ever_activated DESC, b.activated_at DESC, b.created_at DESC",
|
|
1648
|
+
(scope_key,),
|
|
1649
|
+
).fetchall()
|
|
1650
|
+
return [_binding(row) for row in rows]
|
|
1651
|
+
|
|
1652
|
+
def resolve_reference(self, *, scope_key: str, reference: str) -> ThreadBinding:
|
|
1653
|
+
candidates = [
|
|
1654
|
+
binding
|
|
1655
|
+
for binding in self.list_bindings(scope_key)
|
|
1656
|
+
if binding.id == reference or binding.id.startswith(reference)
|
|
1657
|
+
]
|
|
1658
|
+
if not candidates:
|
|
1659
|
+
raise BindingNotFound(reference)
|
|
1660
|
+
if len(candidates) != 1:
|
|
1661
|
+
raise AmbiguousBinding(reference)
|
|
1662
|
+
return candidates[0]
|
|
1663
|
+
|
|
1664
|
+
def bootstrap_project(self, *, alias: str, cwd: str) -> ProjectRecord:
|
|
1665
|
+
now = _now()
|
|
1666
|
+
with self._transaction():
|
|
1667
|
+
self._connection.execute(
|
|
1668
|
+
"""
|
|
1669
|
+
INSERT OR IGNORE INTO projects(
|
|
1670
|
+
alias, cwd, enabled, revision, created_at, updated_at
|
|
1671
|
+
) VALUES (?, ?, 1, 1, ?, ?)
|
|
1672
|
+
""",
|
|
1673
|
+
(alias, cwd, now, now),
|
|
1674
|
+
)
|
|
1675
|
+
return self.get_project(alias, include_deleted=True)
|
|
1676
|
+
|
|
1677
|
+
def register_project(self, *, alias: str, cwd: str) -> ProjectRecord:
|
|
1678
|
+
now = _now()
|
|
1679
|
+
try:
|
|
1680
|
+
with self._transaction():
|
|
1681
|
+
self.require_project_not_deleting(alias)
|
|
1682
|
+
inserted = self._connection.execute(
|
|
1683
|
+
"""
|
|
1684
|
+
INSERT INTO projects(
|
|
1685
|
+
alias, cwd, enabled, revision, created_at, updated_at
|
|
1686
|
+
) VALUES (?, ?, 1, 1, ?, ?)
|
|
1687
|
+
ON CONFLICT(alias) DO UPDATE SET
|
|
1688
|
+
cwd = excluded.cwd, enabled = 1, deleted = 0,
|
|
1689
|
+
revision = projects.revision + 1,
|
|
1690
|
+
created_at = excluded.created_at,
|
|
1691
|
+
updated_at = excluded.updated_at
|
|
1692
|
+
WHERE projects.deleted = 1
|
|
1693
|
+
""",
|
|
1694
|
+
(alias, cwd, now, now),
|
|
1695
|
+
)
|
|
1696
|
+
if inserted.rowcount != 1:
|
|
1697
|
+
raise ProjectConflict(alias)
|
|
1698
|
+
except sqlite3.IntegrityError as error:
|
|
1699
|
+
raise ProjectConflict(alias) from error
|
|
1700
|
+
return self.get_project(alias)
|
|
1701
|
+
|
|
1702
|
+
def get_project(self, alias: str, *, include_deleted: bool = False) -> ProjectRecord:
|
|
1703
|
+
with self._lock:
|
|
1704
|
+
row = self._connection.execute(
|
|
1705
|
+
_PROJECT_SELECT + " WHERE alias = ?"
|
|
1706
|
+
+ ("" if include_deleted else " AND deleted = 0"),
|
|
1707
|
+
(alias,),
|
|
1708
|
+
).fetchone()
|
|
1709
|
+
if row is None:
|
|
1710
|
+
raise ProjectNotFound(alias)
|
|
1711
|
+
return _project_record(row)
|
|
1712
|
+
|
|
1713
|
+
def list_projects(self) -> list[ProjectRecord]:
|
|
1714
|
+
with self._lock:
|
|
1715
|
+
rows = self._connection.execute(
|
|
1716
|
+
_PROJECT_SELECT + " WHERE deleted = 0 ORDER BY alias"
|
|
1717
|
+
).fetchall()
|
|
1718
|
+
return [_project_record(row) for row in rows]
|
|
1719
|
+
|
|
1720
|
+
def set_project_enabled(
|
|
1721
|
+
self,
|
|
1722
|
+
*,
|
|
1723
|
+
alias: str,
|
|
1724
|
+
enabled: bool,
|
|
1725
|
+
expected_revision: int,
|
|
1726
|
+
) -> ProjectRecord:
|
|
1727
|
+
with self._transaction():
|
|
1728
|
+
self.require_project_not_deleting(alias)
|
|
1729
|
+
row = self._connection.execute(
|
|
1730
|
+
_PROJECT_SELECT + " WHERE alias = ? AND deleted = 0",
|
|
1731
|
+
(alias,),
|
|
1732
|
+
).fetchone()
|
|
1733
|
+
if row is None:
|
|
1734
|
+
raise ProjectNotFound(alias)
|
|
1735
|
+
if row["revision"] != expected_revision:
|
|
1736
|
+
raise ProjectRevisionConflict(alias)
|
|
1737
|
+
if bool(row["enabled"]) == enabled:
|
|
1738
|
+
return _project_record(row)
|
|
1739
|
+
self._connection.execute(
|
|
1740
|
+
"""
|
|
1741
|
+
UPDATE projects
|
|
1742
|
+
SET enabled = ?, revision = revision + 1, updated_at = ?
|
|
1743
|
+
WHERE alias = ? AND revision = ?
|
|
1744
|
+
""",
|
|
1745
|
+
(int(enabled), _now(), alias, expected_revision),
|
|
1746
|
+
)
|
|
1747
|
+
return self.get_project(alias)
|
|
1748
|
+
|
|
1749
|
+
def project_delete_in_progress(self, alias: str) -> bool:
|
|
1750
|
+
with self._lock:
|
|
1751
|
+
return alias in self._project_delete_intents
|
|
1752
|
+
|
|
1753
|
+
def require_project_not_deleting(self, alias: str) -> None:
|
|
1754
|
+
with self._lock:
|
|
1755
|
+
if alias in self._project_delete_intents:
|
|
1756
|
+
raise ProjectDeleting(f"Project {alias} 正在删除,请稍后刷新。")
|
|
1757
|
+
|
|
1758
|
+
def preview_project_delete(
|
|
1759
|
+
self,
|
|
1760
|
+
alias: str,
|
|
1761
|
+
*,
|
|
1762
|
+
limit: int = PROJECT_DELETE_LIMIT,
|
|
1763
|
+
extra_side_ids: tuple[str, ...] = (),
|
|
1764
|
+
) -> ProjectDeleteSnapshot:
|
|
1765
|
+
with self._lock:
|
|
1766
|
+
self.require_project_not_deleting(alias)
|
|
1767
|
+
return self._project_delete_snapshot(
|
|
1768
|
+
alias, limit=limit, extra_side_ids=extra_side_ids,
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
def _project_delete_snapshot(
|
|
1772
|
+
self, alias: str, *, limit: int, extra_side_ids: tuple[str, ...] = ()
|
|
1773
|
+
) -> ProjectDeleteSnapshot:
|
|
1774
|
+
if type(limit) is not int or not 1 <= limit <= PROJECT_DELETE_LIMIT:
|
|
1775
|
+
raise ValueError(
|
|
1776
|
+
f"Project deletion limit must be between 1 and {PROJECT_DELETE_LIMIT}"
|
|
1777
|
+
)
|
|
1778
|
+
limit_message = (
|
|
1779
|
+
f"Project {alias} 超过单次删除上限({limit} Sessions / {limit} Side Topics);"
|
|
1780
|
+
"请先分次清理会话。"
|
|
1781
|
+
)
|
|
1782
|
+
if not isinstance(extra_side_ids, tuple) or any(
|
|
1783
|
+
not isinstance(side_id, str) or not side_id for side_id in extra_side_ids
|
|
1784
|
+
):
|
|
1785
|
+
raise ValueError("Extra Side IDs must be a tuple of exact IDs")
|
|
1786
|
+
if len(extra_side_ids) > limit:
|
|
1787
|
+
raise ProjectDeleteLimitExceeded(limit_message)
|
|
1788
|
+
project = self.get_project(alias)
|
|
1789
|
+
binding_rows = self._connection.execute(
|
|
1790
|
+
_BINDING_INVENTORY_SELECT
|
|
1791
|
+
+ " WHERE b.project_alias = ? LIMIT ?",
|
|
1792
|
+
(alias, limit + 1),
|
|
1793
|
+
).fetchall()
|
|
1794
|
+
if len(binding_rows) > limit:
|
|
1795
|
+
raise ProjectDeleteLimitExceeded(limit_message)
|
|
1796
|
+
side_rows = self._connection.execute(
|
|
1797
|
+
_SIDE_TOPIC_SELECT
|
|
1798
|
+
+ " WHERE parent_binding_id IN "
|
|
1799
|
+
"(SELECT binding_id FROM bindings WHERE project_alias = ?) LIMIT ?",
|
|
1800
|
+
(alias, limit + 1),
|
|
1801
|
+
).fetchall()
|
|
1802
|
+
if len(side_rows) > limit:
|
|
1803
|
+
raise ProjectDeleteLimitExceeded(limit_message)
|
|
1804
|
+
side_by_id = {row["side_id"]: _side_topic(row) for row in side_rows}
|
|
1805
|
+
for side_id in extra_side_ids:
|
|
1806
|
+
if side_id in side_by_id:
|
|
1807
|
+
continue
|
|
1808
|
+
row = self._connection.execute(
|
|
1809
|
+
_SIDE_INVENTORY_SELECT + " WHERE st.side_id = ?", (side_id,)
|
|
1810
|
+
).fetchone()
|
|
1811
|
+
if row is None or row["parent_project_alias"] not in {None, alias}:
|
|
1812
|
+
raise ProjectInventoryConflict(
|
|
1813
|
+
f"Project {alias} 的关联 Side 已改变,请刷新后重新确认。"
|
|
1814
|
+
)
|
|
1815
|
+
side_by_id[side_id] = _side_topic(row)
|
|
1816
|
+
if len(side_by_id) > limit:
|
|
1817
|
+
raise ProjectDeleteLimitExceeded(limit_message)
|
|
1818
|
+
bindings = tuple(
|
|
1819
|
+
sorted(
|
|
1820
|
+
(_binding_inventory(row) for row in binding_rows),
|
|
1821
|
+
key=lambda item: item.binding.id,
|
|
1822
|
+
)
|
|
1823
|
+
)
|
|
1824
|
+
sides = tuple(
|
|
1825
|
+
sorted(side_by_id.values(), key=lambda side: side.id)
|
|
1826
|
+
)
|
|
1827
|
+
plan_rows = self._connection.execute(
|
|
1828
|
+
"SELECT plan_id, revision FROM schedule_plans WHERE project_alias=? "
|
|
1829
|
+
"AND deleted=0 ORDER BY plan_id LIMIT ?", (alias, limit + 1),
|
|
1830
|
+
).fetchall()
|
|
1831
|
+
run_rows = self._connection.execute(
|
|
1832
|
+
"SELECT run_id FROM schedule_runs WHERE project_alias=? "
|
|
1833
|
+
"AND (barrier != 'released' OR (target_kind='new_topic' AND error_code='publishing_unknown')) "
|
|
1834
|
+
"ORDER BY run_id LIMIT ?", (alias, limit + 1),
|
|
1835
|
+
).fetchall()
|
|
1836
|
+
if len(plan_rows) > limit or len(run_rows) > limit:
|
|
1837
|
+
raise ProjectDeleteLimitExceeded(
|
|
1838
|
+
f"Project {alias} 的计划或未决调度交接超过单次上限 {limit}。"
|
|
1839
|
+
)
|
|
1840
|
+
scheduled_plans = tuple((row["plan_id"], row["revision"]) for row in plan_rows)
|
|
1841
|
+
scheduled_runs = tuple(self.schedules.get_run(row["run_id"]) for row in run_rows)
|
|
1842
|
+
identity = {
|
|
1843
|
+
"project_alias": alias,
|
|
1844
|
+
"bindings": [
|
|
1845
|
+
[
|
|
1846
|
+
item.binding.id, item.scope.scope_key, item.scope.app_id,
|
|
1847
|
+
item.scope.chat_id, item.scope.kind.value, item.scope.topic_id,
|
|
1848
|
+
item.binding.native_thread_id,
|
|
1849
|
+
]
|
|
1850
|
+
for item in bindings
|
|
1851
|
+
],
|
|
1852
|
+
"sides": [
|
|
1853
|
+
[
|
|
1854
|
+
side.id, side.app_id, side.chat_id, side.topic_id,
|
|
1855
|
+
side.root_message_id, side.parent_binding_id,
|
|
1856
|
+
]
|
|
1857
|
+
for side in sides
|
|
1858
|
+
],
|
|
1859
|
+
"scheduled_plans": scheduled_plans,
|
|
1860
|
+
"scheduled_runs": [
|
|
1861
|
+
[run.id, run.plan_id, run.plan_revision, run.app_id, run.chat_id,
|
|
1862
|
+
run.project_alias, run.phase, run.root_message_id, run.topic_id,
|
|
1863
|
+
run.origin_message_id, run.binding_id, run.initial_turn_id]
|
|
1864
|
+
for run in scheduled_runs
|
|
1865
|
+
],
|
|
1866
|
+
}
|
|
1867
|
+
fingerprint = hashlib.sha256(
|
|
1868
|
+
json.dumps(identity, ensure_ascii=False, separators=(",", ":")).encode()
|
|
1869
|
+
).hexdigest()
|
|
1870
|
+
return ProjectDeleteSnapshot(
|
|
1871
|
+
project, bindings, sides, fingerprint, scheduled_plans, scheduled_runs,
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1874
|
+
def begin_project_delete(
|
|
1875
|
+
self,
|
|
1876
|
+
*,
|
|
1877
|
+
alias: str,
|
|
1878
|
+
expected_revision: int,
|
|
1879
|
+
expected_inventory_fingerprint: str,
|
|
1880
|
+
limit: int = PROJECT_DELETE_LIMIT,
|
|
1881
|
+
extra_side_ids: tuple[str, ...] = (),
|
|
1882
|
+
) -> ProjectDeleteSnapshot:
|
|
1883
|
+
with self._lock:
|
|
1884
|
+
with self._transaction():
|
|
1885
|
+
self.require_project_not_deleting(alias)
|
|
1886
|
+
snapshot = self._project_delete_snapshot(
|
|
1887
|
+
alias, limit=limit, extra_side_ids=extra_side_ids,
|
|
1888
|
+
)
|
|
1889
|
+
if snapshot.project.revision != expected_revision:
|
|
1890
|
+
raise ProjectRevisionConflict(alias)
|
|
1891
|
+
if snapshot.fingerprint != expected_inventory_fingerprint:
|
|
1892
|
+
raise ProjectInventoryConflict(
|
|
1893
|
+
f"Project {alias} 的关联会话已改变,请刷新后重新确认。"
|
|
1894
|
+
)
|
|
1895
|
+
self._connection.execute(
|
|
1896
|
+
"UPDATE projects SET enabled = 0, revision = revision + 1, "
|
|
1897
|
+
"updated_at = ? WHERE alias = ? AND revision = ? AND deleted = 0",
|
|
1898
|
+
(_now(), alias, expected_revision),
|
|
1899
|
+
)
|
|
1900
|
+
self.schedules._tombstone(
|
|
1901
|
+
"project_alias = ?", (alias,), self._wall_clock(),
|
|
1902
|
+
)
|
|
1903
|
+
reserved = ProjectDeleteSnapshot(
|
|
1904
|
+
self.get_project(alias), snapshot.bindings, snapshot.sides,
|
|
1905
|
+
snapshot.fingerprint, snapshot.scheduled_plans,
|
|
1906
|
+
snapshot.scheduled_runs,
|
|
1907
|
+
)
|
|
1908
|
+
self._project_delete_intents[alias] = reserved
|
|
1909
|
+
return reserved
|
|
1910
|
+
|
|
1911
|
+
def release_project_delete(self, *, alias: str, expected_revision: int) -> None:
|
|
1912
|
+
with self._lock:
|
|
1913
|
+
intent = self._project_delete_intents.get(alias)
|
|
1914
|
+
if intent is None:
|
|
1915
|
+
return
|
|
1916
|
+
if intent.project.revision != expected_revision:
|
|
1917
|
+
raise ProjectRevisionConflict(alias)
|
|
1918
|
+
del self._project_delete_intents[alias]
|
|
1919
|
+
|
|
1920
|
+
def finish_project_delete(
|
|
1921
|
+
self,
|
|
1922
|
+
*,
|
|
1923
|
+
alias: str,
|
|
1924
|
+
expected_revision: int,
|
|
1925
|
+
expected_inventory_fingerprint: str,
|
|
1926
|
+
) -> None:
|
|
1927
|
+
with self._lock:
|
|
1928
|
+
with self._transaction():
|
|
1929
|
+
intent = self._project_delete_intents.get(alias)
|
|
1930
|
+
if intent is None:
|
|
1931
|
+
raise ProjectInventoryConflict(f"Project {alias} 没有进行中的删除。")
|
|
1932
|
+
project = self.get_project(alias)
|
|
1933
|
+
if (
|
|
1934
|
+
project.revision != expected_revision
|
|
1935
|
+
or intent.project.revision != expected_revision
|
|
1936
|
+
):
|
|
1937
|
+
raise ProjectRevisionConflict(alias)
|
|
1938
|
+
if intent.fingerprint != expected_inventory_fingerprint:
|
|
1939
|
+
raise ProjectInventoryConflict(alias)
|
|
1940
|
+
if self._connection.execute(
|
|
1941
|
+
"SELECT 1 FROM bindings WHERE project_alias = ? LIMIT 1", (alias,)
|
|
1942
|
+
).fetchone() is not None:
|
|
1943
|
+
raise ProjectInventoryConflict(f"Project {alias} 仍有关联 Sessions。")
|
|
1944
|
+
if self._connection.execute(
|
|
1945
|
+
"SELECT 1 FROM schedule_runs WHERE project_alias=? "
|
|
1946
|
+
"AND (barrier != 'released' OR error_code='publishing_unknown') "
|
|
1947
|
+
"LIMIT 1", (alias,),
|
|
1948
|
+
).fetchone() is not None:
|
|
1949
|
+
raise ProjectInventoryConflict(
|
|
1950
|
+
f"Project {alias} 仍有未完成或未知的定时创建交接。"
|
|
1951
|
+
)
|
|
1952
|
+
for item in intent.bindings:
|
|
1953
|
+
if self._connection.execute(
|
|
1954
|
+
"SELECT 1 FROM side_topics WHERE parent_binding_id = ? "
|
|
1955
|
+
"AND state IN ('creating', 'open') LIMIT 1",
|
|
1956
|
+
(item.binding.id,),
|
|
1957
|
+
).fetchone() is not None:
|
|
1958
|
+
raise ProjectInventoryConflict(f"Project {alias} 仍有未关闭的 Side Topics。")
|
|
1959
|
+
for side in intent.sides:
|
|
1960
|
+
if not self.get_side_topic(side.id).state.terminal:
|
|
1961
|
+
raise ProjectInventoryConflict(
|
|
1962
|
+
f"Project {alias} 仍有未关闭的 Side Topics。"
|
|
1963
|
+
)
|
|
1964
|
+
self._connection.execute(
|
|
1965
|
+
"UPDATE projects SET deleted = 1, enabled = 0, "
|
|
1966
|
+
"revision = revision + 1, updated_at = ? WHERE alias = ?",
|
|
1967
|
+
(_now(), alias),
|
|
1968
|
+
)
|
|
1969
|
+
del self._project_delete_intents[alias]
|
|
1970
|
+
|
|
1971
|
+
def create_side_topic(
|
|
1972
|
+
self,
|
|
1973
|
+
*,
|
|
1974
|
+
app_id: str,
|
|
1975
|
+
chat_id: str,
|
|
1976
|
+
source_message_id: str,
|
|
1977
|
+
parent_binding_id: str,
|
|
1978
|
+
creator_id: str,
|
|
1979
|
+
requires_mention: bool,
|
|
1980
|
+
) -> SideTopicRecord:
|
|
1981
|
+
values = (
|
|
1982
|
+
app_id,
|
|
1983
|
+
chat_id,
|
|
1984
|
+
source_message_id,
|
|
1985
|
+
parent_binding_id,
|
|
1986
|
+
creator_id,
|
|
1987
|
+
)
|
|
1988
|
+
if not all(isinstance(value, str) and value for value in values):
|
|
1989
|
+
raise ValueError("Side Topic identity fields must not be empty")
|
|
1990
|
+
if not isinstance(requires_mention, bool):
|
|
1991
|
+
raise ValueError("requires_mention must be boolean")
|
|
1992
|
+
existing = self.side_topic_for_source(
|
|
1993
|
+
app_id=app_id,
|
|
1994
|
+
source_message_id=source_message_id,
|
|
1995
|
+
)
|
|
1996
|
+
if existing is not None:
|
|
1997
|
+
_require_same_side_reservation(
|
|
1998
|
+
existing,
|
|
1999
|
+
chat_id=chat_id,
|
|
2000
|
+
parent_binding_id=parent_binding_id,
|
|
2001
|
+
creator_id=creator_id,
|
|
2002
|
+
requires_mention=requires_mention,
|
|
2003
|
+
)
|
|
2004
|
+
return existing
|
|
2005
|
+
side_id = self._id_factory()
|
|
2006
|
+
now = _now()
|
|
2007
|
+
try:
|
|
2008
|
+
with self._transaction():
|
|
2009
|
+
parent = self._connection.execute(
|
|
2010
|
+
"SELECT project_alias FROM bindings WHERE binding_id = ?",
|
|
2011
|
+
(parent_binding_id,),
|
|
2012
|
+
).fetchone()
|
|
2013
|
+
if parent is not None:
|
|
2014
|
+
self.require_project_not_deleting(parent["project_alias"])
|
|
2015
|
+
else:
|
|
2016
|
+
for intent in self._project_delete_intents.values():
|
|
2017
|
+
if any(
|
|
2018
|
+
item.binding.id == parent_binding_id
|
|
2019
|
+
for item in intent.bindings
|
|
2020
|
+
) or any(
|
|
2021
|
+
side.parent_binding_id == parent_binding_id
|
|
2022
|
+
for side in intent.sides
|
|
2023
|
+
):
|
|
2024
|
+
raise ProjectDeleting(
|
|
2025
|
+
f"Project {intent.project.alias} 正在删除,请稍后刷新。"
|
|
2026
|
+
)
|
|
2027
|
+
self._connection.execute(
|
|
2028
|
+
"""
|
|
2029
|
+
INSERT INTO side_topics(
|
|
2030
|
+
side_id, app_id, chat_id, topic_id, root_message_id,
|
|
2031
|
+
source_message_id, parent_binding_id, creator_id,
|
|
2032
|
+
requires_mention, state, created_at, updated_at
|
|
2033
|
+
) VALUES (?, ?, ?, NULL, NULL, ?, ?, ?, ?, 'creating', ?, ?)
|
|
2034
|
+
""",
|
|
2035
|
+
(
|
|
2036
|
+
side_id,
|
|
2037
|
+
app_id,
|
|
2038
|
+
chat_id,
|
|
2039
|
+
source_message_id,
|
|
2040
|
+
parent_binding_id,
|
|
2041
|
+
creator_id,
|
|
2042
|
+
int(requires_mention),
|
|
2043
|
+
now,
|
|
2044
|
+
now,
|
|
2045
|
+
),
|
|
2046
|
+
)
|
|
2047
|
+
except sqlite3.IntegrityError as error:
|
|
2048
|
+
existing = self.side_topic_for_source(
|
|
2049
|
+
app_id=app_id,
|
|
2050
|
+
source_message_id=source_message_id,
|
|
2051
|
+
)
|
|
2052
|
+
if existing is not None:
|
|
2053
|
+
_require_same_side_reservation(
|
|
2054
|
+
existing,
|
|
2055
|
+
chat_id=chat_id,
|
|
2056
|
+
parent_binding_id=parent_binding_id,
|
|
2057
|
+
creator_id=creator_id,
|
|
2058
|
+
requires_mention=requires_mention,
|
|
2059
|
+
)
|
|
2060
|
+
return existing
|
|
2061
|
+
raise SideTopicConflict("Side Topic identity is already reserved") from error
|
|
2062
|
+
return self.get_side_topic(side_id)
|
|
2063
|
+
|
|
2064
|
+
def set_side_topic_root(
|
|
2065
|
+
self,
|
|
2066
|
+
side_id: str,
|
|
2067
|
+
root_message_id: str,
|
|
2068
|
+
) -> SideTopicRecord:
|
|
2069
|
+
if not root_message_id:
|
|
2070
|
+
raise ValueError("root_message_id must not be empty")
|
|
2071
|
+
with self._transaction():
|
|
2072
|
+
row = self._connection.execute(
|
|
2073
|
+
"SELECT root_message_id, state FROM side_topics WHERE side_id = ?",
|
|
2074
|
+
(side_id,),
|
|
2075
|
+
).fetchone()
|
|
2076
|
+
if row is None:
|
|
2077
|
+
raise SideTopicNotFound(side_id)
|
|
2078
|
+
if row["root_message_id"] == root_message_id:
|
|
2079
|
+
return self.get_side_topic(side_id)
|
|
2080
|
+
if row["root_message_id"] is not None or row["state"] != "creating":
|
|
2081
|
+
raise SideTopicConflict("Side Topic root is write-once while creating")
|
|
2082
|
+
try:
|
|
2083
|
+
self._connection.execute(
|
|
2084
|
+
"""
|
|
2085
|
+
UPDATE side_topics
|
|
2086
|
+
SET root_message_id = ?, updated_at = ?
|
|
2087
|
+
WHERE side_id = ?
|
|
2088
|
+
""",
|
|
2089
|
+
(root_message_id, _now(), side_id),
|
|
2090
|
+
)
|
|
2091
|
+
except sqlite3.IntegrityError as error:
|
|
2092
|
+
raise SideTopicConflict("Side Topic root is already reserved") from error
|
|
2093
|
+
return self.get_side_topic(side_id)
|
|
2094
|
+
|
|
2095
|
+
def open_side_topic(self, side_id: str, topic_id: str) -> SideTopicRecord:
|
|
2096
|
+
if not topic_id:
|
|
2097
|
+
raise ValueError("topic_id must not be empty")
|
|
2098
|
+
with self._transaction():
|
|
2099
|
+
row = self._connection.execute(
|
|
2100
|
+
"""
|
|
2101
|
+
SELECT topic_id, root_message_id, state
|
|
2102
|
+
FROM side_topics WHERE side_id = ?
|
|
2103
|
+
""",
|
|
2104
|
+
(side_id,),
|
|
2105
|
+
).fetchone()
|
|
2106
|
+
if row is None:
|
|
2107
|
+
raise SideTopicNotFound(side_id)
|
|
2108
|
+
if row["state"] == "open" and row["topic_id"] == topic_id:
|
|
2109
|
+
return self.get_side_topic(side_id)
|
|
2110
|
+
if (
|
|
2111
|
+
row["state"] != "creating"
|
|
2112
|
+
or row["root_message_id"] is None
|
|
2113
|
+
or row["topic_id"] not in {None, topic_id}
|
|
2114
|
+
):
|
|
2115
|
+
raise SideTopicConflict("Side Topic cannot transition to open")
|
|
2116
|
+
try:
|
|
2117
|
+
self._connection.execute(
|
|
2118
|
+
"""
|
|
2119
|
+
UPDATE side_topics
|
|
2120
|
+
SET topic_id = ?, state = 'open', updated_at = ?
|
|
2121
|
+
WHERE side_id = ?
|
|
2122
|
+
""",
|
|
2123
|
+
(topic_id, _now(), side_id),
|
|
2124
|
+
)
|
|
2125
|
+
except sqlite3.IntegrityError as error:
|
|
2126
|
+
raise SideTopicConflict("Side Topic is already reserved") from error
|
|
2127
|
+
return self.get_side_topic(side_id)
|
|
2128
|
+
|
|
2129
|
+
def set_side_topic_topic(
|
|
2130
|
+
self,
|
|
2131
|
+
side_id: str,
|
|
2132
|
+
topic_id: str,
|
|
2133
|
+
) -> SideTopicRecord:
|
|
2134
|
+
if not topic_id:
|
|
2135
|
+
raise ValueError("topic_id must not be empty")
|
|
2136
|
+
with self._transaction():
|
|
2137
|
+
row = self._connection.execute(
|
|
2138
|
+
"""
|
|
2139
|
+
SELECT topic_id, root_message_id, state
|
|
2140
|
+
FROM side_topics WHERE side_id = ?
|
|
2141
|
+
""",
|
|
2142
|
+
(side_id,),
|
|
2143
|
+
).fetchone()
|
|
2144
|
+
if row is None:
|
|
2145
|
+
raise SideTopicNotFound(side_id)
|
|
2146
|
+
if row["topic_id"] == topic_id:
|
|
2147
|
+
return self.get_side_topic(side_id)
|
|
2148
|
+
if (
|
|
2149
|
+
row["state"] != "creating"
|
|
2150
|
+
or row["root_message_id"] is None
|
|
2151
|
+
or row["topic_id"] is not None
|
|
2152
|
+
):
|
|
2153
|
+
raise SideTopicConflict(
|
|
2154
|
+
"Side Topic ID is write-once after its root is known"
|
|
2155
|
+
)
|
|
2156
|
+
try:
|
|
2157
|
+
self._connection.execute(
|
|
2158
|
+
"""
|
|
2159
|
+
UPDATE side_topics
|
|
2160
|
+
SET topic_id = ?, updated_at = ?
|
|
2161
|
+
WHERE side_id = ?
|
|
2162
|
+
""",
|
|
2163
|
+
(topic_id, _now(), side_id),
|
|
2164
|
+
)
|
|
2165
|
+
except sqlite3.IntegrityError as error:
|
|
2166
|
+
raise SideTopicConflict("Side Topic ID is already reserved") from error
|
|
2167
|
+
return self.get_side_topic(side_id)
|
|
2168
|
+
|
|
2169
|
+
def transition_side_topic(
|
|
2170
|
+
self,
|
|
2171
|
+
side_id: str,
|
|
2172
|
+
state: SideTopicState,
|
|
2173
|
+
) -> SideTopicRecord:
|
|
2174
|
+
if not state.terminal:
|
|
2175
|
+
raise ValueError("Side Topic transition target must be terminal")
|
|
2176
|
+
with self._transaction():
|
|
2177
|
+
row = self._connection.execute(
|
|
2178
|
+
"SELECT state FROM side_topics WHERE side_id = ?",
|
|
2179
|
+
(side_id,),
|
|
2180
|
+
).fetchone()
|
|
2181
|
+
if row is None:
|
|
2182
|
+
raise SideTopicNotFound(side_id)
|
|
2183
|
+
current = SideTopicState(row["state"])
|
|
2184
|
+
if current is state:
|
|
2185
|
+
return self.get_side_topic(side_id)
|
|
2186
|
+
if current.terminal:
|
|
2187
|
+
raise SideTopicConflict(
|
|
2188
|
+
f"Side Topic is already terminal: {current.value}"
|
|
2189
|
+
)
|
|
2190
|
+
self._connection.execute(
|
|
2191
|
+
"""
|
|
2192
|
+
UPDATE side_topics SET state = ?, updated_at = ?
|
|
2193
|
+
WHERE side_id = ?
|
|
2194
|
+
""",
|
|
2195
|
+
(state.value, _now(), side_id),
|
|
2196
|
+
)
|
|
2197
|
+
return self.get_side_topic(side_id)
|
|
2198
|
+
|
|
2199
|
+
def touch_side_topic(self, side_id: str) -> SideTopicRecord:
|
|
2200
|
+
with self._transaction():
|
|
2201
|
+
cursor = self._connection.execute(
|
|
2202
|
+
"""
|
|
2203
|
+
UPDATE side_topics SET updated_at = ?
|
|
2204
|
+
WHERE side_id = ? AND state = 'open'
|
|
2205
|
+
""",
|
|
2206
|
+
(_now(), side_id),
|
|
2207
|
+
)
|
|
2208
|
+
if cursor.rowcount != 1:
|
|
2209
|
+
row = self._connection.execute(
|
|
2210
|
+
"SELECT 1 FROM side_topics WHERE side_id = ?",
|
|
2211
|
+
(side_id,),
|
|
2212
|
+
).fetchone()
|
|
2213
|
+
if row is None:
|
|
2214
|
+
raise SideTopicNotFound(side_id)
|
|
2215
|
+
raise SideTopicConflict("Side Topic is not open")
|
|
2216
|
+
return self.get_side_topic(side_id)
|
|
2217
|
+
|
|
2218
|
+
def get_side_topic(self, side_id: str) -> SideTopicRecord:
|
|
2219
|
+
with self._lock:
|
|
2220
|
+
row = self._connection.execute(
|
|
2221
|
+
_SIDE_TOPIC_SELECT + " WHERE side_id = ?",
|
|
2222
|
+
(side_id,),
|
|
2223
|
+
).fetchone()
|
|
2224
|
+
if row is None:
|
|
2225
|
+
raise SideTopicNotFound(side_id)
|
|
2226
|
+
return _side_topic(row)
|
|
2227
|
+
|
|
2228
|
+
def side_topic_for_source(
|
|
2229
|
+
self,
|
|
2230
|
+
*,
|
|
2231
|
+
app_id: str,
|
|
2232
|
+
source_message_id: str,
|
|
2233
|
+
) -> SideTopicRecord | None:
|
|
2234
|
+
with self._lock:
|
|
2235
|
+
row = self._connection.execute(
|
|
2236
|
+
_SIDE_TOPIC_SELECT
|
|
2237
|
+
+ " WHERE app_id = ? AND source_message_id = ?",
|
|
2238
|
+
(app_id, source_message_id),
|
|
2239
|
+
).fetchone()
|
|
2240
|
+
return _side_topic(row) if row is not None else None
|
|
2241
|
+
|
|
2242
|
+
def side_topic_for_message(
|
|
2243
|
+
self,
|
|
2244
|
+
*,
|
|
2245
|
+
app_id: str,
|
|
2246
|
+
chat_id: str,
|
|
2247
|
+
topic_id: str | None,
|
|
2248
|
+
root_message_id: str | None = None,
|
|
2249
|
+
) -> SideTopicRecord | None:
|
|
2250
|
+
topic_record: SideTopicRecord | None = None
|
|
2251
|
+
root_record: SideTopicRecord | None = None
|
|
2252
|
+
with self._lock:
|
|
2253
|
+
if topic_id:
|
|
2254
|
+
row = self._connection.execute(
|
|
2255
|
+
_SIDE_TOPIC_SELECT
|
|
2256
|
+
+ " WHERE app_id = ? AND chat_id = ? AND topic_id = ?",
|
|
2257
|
+
(app_id, chat_id, topic_id),
|
|
2258
|
+
).fetchone()
|
|
2259
|
+
if row is not None:
|
|
2260
|
+
topic_record = _side_topic(row)
|
|
2261
|
+
if root_message_id:
|
|
2262
|
+
row = self._connection.execute(
|
|
2263
|
+
_SIDE_TOPIC_SELECT
|
|
2264
|
+
+ " WHERE app_id = ? AND chat_id = ? AND root_message_id = ?",
|
|
2265
|
+
(app_id, chat_id, root_message_id),
|
|
2266
|
+
).fetchone()
|
|
2267
|
+
if row is not None:
|
|
2268
|
+
root_record = _side_topic(row)
|
|
2269
|
+
|
|
2270
|
+
if topic_record is not None:
|
|
2271
|
+
if root_message_id is None:
|
|
2272
|
+
return topic_record
|
|
2273
|
+
if root_record is None or root_record.id != topic_record.id:
|
|
2274
|
+
raise SideTopicConflict(
|
|
2275
|
+
"Side topic and root message identities do not match"
|
|
2276
|
+
)
|
|
2277
|
+
return topic_record
|
|
2278
|
+
if root_record is not None:
|
|
2279
|
+
if topic_id is not None and root_record.topic_id is not None:
|
|
2280
|
+
raise SideTopicConflict(
|
|
2281
|
+
"Side root message belongs to a different known topic"
|
|
2282
|
+
)
|
|
2283
|
+
return root_record
|
|
2284
|
+
return None
|
|
2285
|
+
|
|
2286
|
+
def list_side_topics(self) -> list[SideTopicRecord]:
|
|
2287
|
+
with self._lock:
|
|
2288
|
+
rows = self._connection.execute(
|
|
2289
|
+
_SIDE_TOPIC_SELECT + " ORDER BY created_at, side_id"
|
|
2290
|
+
).fetchall()
|
|
2291
|
+
return [_side_topic(row) for row in rows]
|
|
2292
|
+
|
|
2293
|
+
async def query_bindings(
|
|
2294
|
+
self,
|
|
2295
|
+
*,
|
|
2296
|
+
query: BindingQuery = BindingQuery(),
|
|
2297
|
+
cursor: BindingCursor | None = None,
|
|
2298
|
+
limit: int = 25,
|
|
2299
|
+
deadline_seconds: float = 0.5,
|
|
2300
|
+
) -> BindingPage:
|
|
2301
|
+
"""Read one global Binding inventory page off the shared event loop."""
|
|
2302
|
+
|
|
2303
|
+
page_limit = _validate_page_limit(limit, maximum=100)
|
|
2304
|
+
statement, parameters = _binding_inventory_statement(
|
|
2305
|
+
query=query,
|
|
2306
|
+
cursor=cursor,
|
|
2307
|
+
limit=page_limit + 1,
|
|
2308
|
+
)
|
|
2309
|
+
rows = await self._read_rows(
|
|
2310
|
+
statement,
|
|
2311
|
+
parameters,
|
|
2312
|
+
deadline_seconds=deadline_seconds,
|
|
2313
|
+
)
|
|
2314
|
+
has_more = len(rows) > page_limit
|
|
2315
|
+
selected = rows[:page_limit]
|
|
2316
|
+
items = tuple(_binding_inventory(row) for row in selected)
|
|
2317
|
+
next_cursor = None
|
|
2318
|
+
if has_more and selected:
|
|
2319
|
+
last = selected[-1]
|
|
2320
|
+
next_cursor = BindingCursor(last["created_at"], last["binding_id"])
|
|
2321
|
+
return BindingPage(items=items, next_cursor=next_cursor)
|
|
2322
|
+
|
|
2323
|
+
async def query_side_topics(
|
|
2324
|
+
self,
|
|
2325
|
+
*,
|
|
2326
|
+
query: SideTopicQuery = SideTopicQuery(),
|
|
2327
|
+
cursor: SideTopicCursor | None = None,
|
|
2328
|
+
limit: int = 25,
|
|
2329
|
+
deadline_seconds: float = 0.5,
|
|
2330
|
+
) -> SideTopicPage:
|
|
2331
|
+
"""Read one global Side Topic inventory page off the event loop."""
|
|
2332
|
+
|
|
2333
|
+
page_limit = _validate_page_limit(limit)
|
|
2334
|
+
statement, parameters = _side_inventory_statement(
|
|
2335
|
+
query=query,
|
|
2336
|
+
cursor=cursor,
|
|
2337
|
+
limit=page_limit + 1,
|
|
2338
|
+
)
|
|
2339
|
+
rows = await self._read_rows(
|
|
2340
|
+
statement,
|
|
2341
|
+
parameters,
|
|
2342
|
+
deadline_seconds=deadline_seconds,
|
|
2343
|
+
)
|
|
2344
|
+
has_more = len(rows) > page_limit
|
|
2345
|
+
selected = rows[:page_limit]
|
|
2346
|
+
items = tuple(_side_inventory(row) for row in selected)
|
|
2347
|
+
next_cursor = None
|
|
2348
|
+
if has_more and selected:
|
|
2349
|
+
last = selected[-1]
|
|
2350
|
+
next_cursor = SideTopicCursor(last["created_at"], last["side_id"])
|
|
2351
|
+
return SideTopicPage(items=items, next_cursor=next_cursor)
|
|
2352
|
+
|
|
2353
|
+
async def query_project_aggregates(
|
|
2354
|
+
self,
|
|
2355
|
+
*,
|
|
2356
|
+
cursor: str | None = None,
|
|
2357
|
+
limit: int = 25,
|
|
2358
|
+
deadline_seconds: float = 0.5,
|
|
2359
|
+
) -> ProjectAggregatePage:
|
|
2360
|
+
"""Read Project rows with Channel-owned Binding aggregates."""
|
|
2361
|
+
|
|
2362
|
+
page_limit = _validate_page_limit(limit)
|
|
2363
|
+
where = " WHERE p.deleted = 0"
|
|
2364
|
+
parameters: list[object] = []
|
|
2365
|
+
if cursor is not None:
|
|
2366
|
+
if not cursor:
|
|
2367
|
+
raise ValueError("Project cursor must not be empty")
|
|
2368
|
+
where += " AND p.alias > ?"
|
|
2369
|
+
parameters.append(cursor)
|
|
2370
|
+
parameters.append(page_limit + 1)
|
|
2371
|
+
rows = await self._read_rows(
|
|
2372
|
+
_PROJECT_AGGREGATE_SELECT
|
|
2373
|
+
+ where
|
|
2374
|
+
+ " GROUP BY p.alias ORDER BY p.alias LIMIT ?",
|
|
2375
|
+
parameters,
|
|
2376
|
+
deadline_seconds=deadline_seconds,
|
|
2377
|
+
)
|
|
2378
|
+
has_more = len(rows) > page_limit
|
|
2379
|
+
selected = rows[:page_limit]
|
|
2380
|
+
items = tuple(_project_aggregate(row) for row in selected)
|
|
2381
|
+
next_cursor = selected[-1]["alias"] if has_more and selected else None
|
|
2382
|
+
return ProjectAggregatePage(items=items, next_cursor=next_cursor)
|
|
2383
|
+
|
|
2384
|
+
async def project_aliases_for_native_threads(
|
|
2385
|
+
self,
|
|
2386
|
+
thread_ids: Sequence[str],
|
|
2387
|
+
*,
|
|
2388
|
+
expected_projects: Sequence[ProjectAggregate] = (),
|
|
2389
|
+
deadline_seconds: float = 0.5,
|
|
2390
|
+
) -> dict[str, str]:
|
|
2391
|
+
"""Map a bounded native catalog snapshot back to Channel Projects."""
|
|
2392
|
+
|
|
2393
|
+
unique = tuple(dict.fromkeys(thread_ids))
|
|
2394
|
+
if len(unique) > 100_000:
|
|
2395
|
+
raise ValueError("native Thread lookup exceeds the bounded catalog limit")
|
|
2396
|
+
if any(not isinstance(thread_id, str) or not thread_id for thread_id in unique):
|
|
2397
|
+
raise ValueError("native Thread IDs must be non-empty strings")
|
|
2398
|
+
if not unique:
|
|
2399
|
+
return {}
|
|
2400
|
+
|
|
2401
|
+
def operation(connection: sqlite3.Connection) -> dict[str, str]:
|
|
2402
|
+
connection.execute("BEGIN")
|
|
2403
|
+
try:
|
|
2404
|
+
# Counts and native-ID membership must describe the same local
|
|
2405
|
+
# snapshot, including across lookup chunks and concurrent writes.
|
|
2406
|
+
for expected in expected_projects:
|
|
2407
|
+
row = connection.execute(
|
|
2408
|
+
_PROJECT_AGGREGATE_SELECT
|
|
2409
|
+
+ " WHERE p.deleted = 0 AND p.alias = ? GROUP BY p.alias",
|
|
2410
|
+
(expected.project.alias,),
|
|
2411
|
+
).fetchone()
|
|
2412
|
+
if row is None or _project_aggregate(row) != expected:
|
|
2413
|
+
raise ValueError("Project inventory changed during native lookup")
|
|
2414
|
+
result: dict[str, str] = {}
|
|
2415
|
+
for offset in range(0, len(unique), 500):
|
|
2416
|
+
chunk = unique[offset : offset + 500]
|
|
2417
|
+
placeholders = ",".join("?" for _ in chunk)
|
|
2418
|
+
rows = connection.execute(
|
|
2419
|
+
"SELECT native_thread_id, project_alias FROM bindings "
|
|
2420
|
+
f"WHERE native_thread_id IN ({placeholders})",
|
|
2421
|
+
chunk,
|
|
2422
|
+
).fetchall()
|
|
2423
|
+
result.update(
|
|
2424
|
+
(row["native_thread_id"], row["project_alias"]) for row in rows
|
|
2425
|
+
)
|
|
2426
|
+
return result
|
|
2427
|
+
finally:
|
|
2428
|
+
connection.rollback()
|
|
2429
|
+
|
|
2430
|
+
return await self._submit_query(
|
|
2431
|
+
operation,
|
|
2432
|
+
deadline_seconds=deadline_seconds,
|
|
2433
|
+
)
|
|
2434
|
+
|
|
2435
|
+
async def _read_rows(
|
|
2436
|
+
self,
|
|
2437
|
+
statement: str,
|
|
2438
|
+
parameters: Sequence[object] = (),
|
|
2439
|
+
*,
|
|
2440
|
+
deadline_seconds: float,
|
|
2441
|
+
) -> list[sqlite3.Row]:
|
|
2442
|
+
def operation(connection: sqlite3.Connection) -> list[sqlite3.Row]:
|
|
2443
|
+
return connection.execute(statement, tuple(parameters)).fetchall()
|
|
2444
|
+
|
|
2445
|
+
result = await self._submit_query(
|
|
2446
|
+
operation,
|
|
2447
|
+
deadline_seconds=deadline_seconds,
|
|
2448
|
+
)
|
|
2449
|
+
return result
|
|
2450
|
+
|
|
2451
|
+
async def _submit_query(
|
|
2452
|
+
self,
|
|
2453
|
+
operation: Callable[[sqlite3.Connection], "_QueryResult"],
|
|
2454
|
+
*,
|
|
2455
|
+
deadline_seconds: float,
|
|
2456
|
+
) -> "_QueryResult":
|
|
2457
|
+
if not math.isfinite(deadline_seconds) or deadline_seconds <= 0:
|
|
2458
|
+
raise ValueError("query deadline must be positive and finite")
|
|
2459
|
+
deadline = time.monotonic() + deadline_seconds
|
|
2460
|
+
with self._query_state_lock:
|
|
2461
|
+
if self._query_closing:
|
|
2462
|
+
raise BindingQueryClosed("Binding Store query reader is closed")
|
|
2463
|
+
if len(self._query_futures) >= _ADMIN_QUERY_CAPACITY:
|
|
2464
|
+
raise BindingQueryBusy("Admin query capacity is full")
|
|
2465
|
+
future = self._query_executor.submit(
|
|
2466
|
+
self._execute_query,
|
|
2467
|
+
operation,
|
|
2468
|
+
deadline,
|
|
2469
|
+
)
|
|
2470
|
+
self._query_futures.add(future)
|
|
2471
|
+
future.add_done_callback(self._query_finished)
|
|
2472
|
+
wrapped = asyncio.wrap_future(future)
|
|
2473
|
+
wrapped.add_done_callback(_consume_query_exception)
|
|
2474
|
+
# wait() never cancels the worker future. Keep counting queued work even
|
|
2475
|
+
# after its caller leaves: cancelling it would leave executor queue items
|
|
2476
|
+
# behind while returning capacity early, allowing an unbounded backlog.
|
|
2477
|
+
remaining = deadline - time.monotonic()
|
|
2478
|
+
if remaining > 0:
|
|
2479
|
+
done, _ = await asyncio.wait((wrapped,), timeout=remaining)
|
|
2480
|
+
if done:
|
|
2481
|
+
return wrapped.result()
|
|
2482
|
+
if future.done():
|
|
2483
|
+
return future.result()
|
|
2484
|
+
raise BindingQueryTimeout("Binding Store query exceeded its deadline")
|
|
2485
|
+
|
|
2486
|
+
def _execute_query(
|
|
2487
|
+
self,
|
|
2488
|
+
operation: Callable[[sqlite3.Connection], "_QueryResult"],
|
|
2489
|
+
deadline: float,
|
|
2490
|
+
) -> "_QueryResult":
|
|
2491
|
+
expired = False
|
|
2492
|
+
|
|
2493
|
+
def progress() -> int:
|
|
2494
|
+
nonlocal expired
|
|
2495
|
+
expired = time.monotonic() >= deadline
|
|
2496
|
+
return int(expired)
|
|
2497
|
+
|
|
2498
|
+
connection = self._query_connection or self._connection
|
|
2499
|
+
lock = self._lock if self._query_connection is None else nullcontext()
|
|
2500
|
+
with lock:
|
|
2501
|
+
if time.monotonic() >= deadline:
|
|
2502
|
+
raise BindingQueryTimeout("Binding Store query exceeded its deadline")
|
|
2503
|
+
connection.set_progress_handler(progress, 100)
|
|
2504
|
+
try:
|
|
2505
|
+
result = operation(connection)
|
|
2506
|
+
# Short statements may not reach the progress callback interval.
|
|
2507
|
+
if time.monotonic() >= deadline:
|
|
2508
|
+
raise BindingQueryTimeout("Binding Store query exceeded its deadline")
|
|
2509
|
+
return result
|
|
2510
|
+
except sqlite3.OperationalError as error:
|
|
2511
|
+
message = str(error).lower()
|
|
2512
|
+
if expired or "interrupted" in message:
|
|
2513
|
+
raise BindingQueryTimeout(
|
|
2514
|
+
"Binding Store query exceeded its deadline"
|
|
2515
|
+
) from error
|
|
2516
|
+
if "locked" in message or "busy" in message:
|
|
2517
|
+
raise BindingQueryBusy("Binding Store query reader is busy") from error
|
|
2518
|
+
raise
|
|
2519
|
+
finally:
|
|
2520
|
+
connection.set_progress_handler(None, 0)
|
|
2521
|
+
|
|
2522
|
+
def _query_finished(
|
|
2523
|
+
self,
|
|
2524
|
+
future: concurrent.futures.Future[object],
|
|
2525
|
+
) -> None:
|
|
2526
|
+
with self._query_state_lock:
|
|
2527
|
+
self._query_futures.discard(future)
|
|
2528
|
+
|
|
2529
|
+
async def drain_queries(self) -> None:
|
|
2530
|
+
"""Wait for every already-submitted Admin read, including cancelled callers."""
|
|
2531
|
+
|
|
2532
|
+
while True:
|
|
2533
|
+
with self._query_state_lock:
|
|
2534
|
+
futures = tuple(self._query_futures)
|
|
2535
|
+
if not futures:
|
|
2536
|
+
return
|
|
2537
|
+
wrapped = [asyncio.wrap_future(future) for future in futures]
|
|
2538
|
+
for waiter in wrapped:
|
|
2539
|
+
waiter.add_done_callback(_consume_query_exception)
|
|
2540
|
+
await asyncio.wait(wrapped)
|
|
2541
|
+
|
|
2542
|
+
async def aclose(self) -> None:
|
|
2543
|
+
"""Close query admission, drain it, then close both owned connections."""
|
|
2544
|
+
|
|
2545
|
+
with self._query_state_lock:
|
|
2546
|
+
if self._closed:
|
|
2547
|
+
return
|
|
2548
|
+
self._query_closing = True
|
|
2549
|
+
await self.drain_queries()
|
|
2550
|
+
if self._query_connection is not None:
|
|
2551
|
+
await asyncio.wrap_future(
|
|
2552
|
+
self._query_executor.submit(self._query_connection.close)
|
|
2553
|
+
)
|
|
2554
|
+
self._query_connection = None
|
|
2555
|
+
await asyncio.to_thread(
|
|
2556
|
+
self._query_executor.shutdown,
|
|
2557
|
+
wait=True,
|
|
2558
|
+
cancel_futures=False,
|
|
2559
|
+
)
|
|
2560
|
+
with self._lock:
|
|
2561
|
+
self._connection.close()
|
|
2562
|
+
with self._query_state_lock:
|
|
2563
|
+
self._closed = True
|
|
2564
|
+
|
|
2565
|
+
def seen(self, key: str) -> bool:
|
|
2566
|
+
now = self._wall_clock()
|
|
2567
|
+
with self._lock:
|
|
2568
|
+
row = self._connection.execute(
|
|
2569
|
+
"SELECT expires_at FROM dedup_keys WHERE dedup_key = ?",
|
|
2570
|
+
(key,),
|
|
2571
|
+
).fetchone()
|
|
2572
|
+
if row is None:
|
|
2573
|
+
return False
|
|
2574
|
+
if row["expires_at"] <= now:
|
|
2575
|
+
self._connection.execute(
|
|
2576
|
+
"DELETE FROM dedup_keys WHERE dedup_key = ?",
|
|
2577
|
+
(key,),
|
|
2578
|
+
)
|
|
2579
|
+
return False
|
|
2580
|
+
return True
|
|
2581
|
+
|
|
2582
|
+
def mark(self, key: str, ttl_seconds: int) -> None:
|
|
2583
|
+
if ttl_seconds <= 0:
|
|
2584
|
+
raise ValueError("ttl_seconds must be positive")
|
|
2585
|
+
now = self._wall_clock()
|
|
2586
|
+
with self._lock:
|
|
2587
|
+
self._connection.execute(
|
|
2588
|
+
"DELETE FROM dedup_keys WHERE expires_at <= ?",
|
|
2589
|
+
(now,),
|
|
2590
|
+
)
|
|
2591
|
+
self._connection.execute(
|
|
2592
|
+
"""
|
|
2593
|
+
INSERT INTO dedup_keys(dedup_key, expires_at) VALUES (?, ?)
|
|
2594
|
+
ON CONFLICT(dedup_key) DO UPDATE SET expires_at = excluded.expires_at
|
|
2595
|
+
""",
|
|
2596
|
+
(key, now + ttl_seconds),
|
|
2597
|
+
)
|
|
2598
|
+
|
|
2599
|
+
def close(self) -> None:
|
|
2600
|
+
"""Synchronous compatibility close; async services should await ``aclose``."""
|
|
2601
|
+
|
|
2602
|
+
with self._query_state_lock:
|
|
2603
|
+
if self._closed:
|
|
2604
|
+
return
|
|
2605
|
+
self._query_closing = True
|
|
2606
|
+
futures = tuple(self._query_futures)
|
|
2607
|
+
for future in futures:
|
|
2608
|
+
try:
|
|
2609
|
+
future.result()
|
|
2610
|
+
except BaseException:
|
|
2611
|
+
pass
|
|
2612
|
+
if self._query_connection is not None:
|
|
2613
|
+
self._query_executor.submit(self._query_connection.close).result()
|
|
2614
|
+
self._query_connection = None
|
|
2615
|
+
self._query_executor.shutdown(wait=True, cancel_futures=False)
|
|
2616
|
+
with self._lock:
|
|
2617
|
+
self._connection.close()
|
|
2618
|
+
with self._query_state_lock:
|
|
2619
|
+
self._closed = True
|
|
2620
|
+
|
|
2621
|
+
def _transaction(self):
|
|
2622
|
+
return _Transaction(self._connection, self._lock)
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
class _Transaction:
|
|
2626
|
+
def __init__(self, connection: sqlite3.Connection, lock: threading.RLock) -> None:
|
|
2627
|
+
self._connection = connection
|
|
2628
|
+
self._lock = lock
|
|
2629
|
+
|
|
2630
|
+
def __enter__(self) -> None:
|
|
2631
|
+
self._lock.acquire()
|
|
2632
|
+
try:
|
|
2633
|
+
self._connection.execute("BEGIN IMMEDIATE")
|
|
2634
|
+
except BaseException:
|
|
2635
|
+
self._lock.release()
|
|
2636
|
+
raise
|
|
2637
|
+
|
|
2638
|
+
def __exit__(self, exc_type, _exc, _tb) -> None:
|
|
2639
|
+
try:
|
|
2640
|
+
self._connection.execute("COMMIT" if exc_type is None else "ROLLBACK")
|
|
2641
|
+
finally:
|
|
2642
|
+
self._lock.release()
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
_QueryResult = TypeVar("_QueryResult")
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
def _consume_query_exception(future: asyncio.Future[object]) -> None:
|
|
2649
|
+
# A timed-out/cancelled caller may no longer be awaiting this result.
|
|
2650
|
+
if not future.cancelled():
|
|
2651
|
+
future.exception()
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
_COMPATIBLE_INDEXES = (
|
|
2655
|
+
"""
|
|
2656
|
+
CREATE INDEX IF NOT EXISTS bindings_global_created
|
|
2657
|
+
ON bindings(created_at DESC, binding_id DESC)
|
|
2658
|
+
""",
|
|
2659
|
+
"""
|
|
2660
|
+
CREATE INDEX IF NOT EXISTS bindings_project_created
|
|
2661
|
+
ON bindings(project_alias, created_at DESC, binding_id DESC)
|
|
2662
|
+
""",
|
|
2663
|
+
"""
|
|
2664
|
+
CREATE INDEX IF NOT EXISTS bindings_scope_created
|
|
2665
|
+
ON bindings(scope_key, created_at DESC, binding_id DESC)
|
|
2666
|
+
""",
|
|
2667
|
+
"""
|
|
2668
|
+
CREATE INDEX IF NOT EXISTS scopes_kind_chat_topic
|
|
2669
|
+
ON scopes(kind, chat_id, topic_id, scope_key)
|
|
2670
|
+
""",
|
|
2671
|
+
"""
|
|
2672
|
+
CREATE INDEX IF NOT EXISTS scopes_chat_topic_kind
|
|
2673
|
+
ON scopes(chat_id, topic_id, kind, scope_key)
|
|
2674
|
+
""",
|
|
2675
|
+
"""
|
|
2676
|
+
CREATE INDEX IF NOT EXISTS side_topics_global_created
|
|
2677
|
+
ON side_topics(created_at DESC, side_id DESC)
|
|
2678
|
+
""",
|
|
2679
|
+
"""
|
|
2680
|
+
CREATE INDEX IF NOT EXISTS side_topics_state_created
|
|
2681
|
+
ON side_topics(state, created_at DESC, side_id DESC)
|
|
2682
|
+
""",
|
|
2683
|
+
"""
|
|
2684
|
+
CREATE INDEX IF NOT EXISTS side_topics_chat_topic_created
|
|
2685
|
+
ON side_topics(chat_id, topic_id, created_at DESC, side_id DESC)
|
|
2686
|
+
""",
|
|
2687
|
+
"""
|
|
2688
|
+
CREATE INDEX IF NOT EXISTS side_topics_parent_created
|
|
2689
|
+
ON side_topics(parent_binding_id, created_at DESC, side_id DESC)
|
|
2690
|
+
""",
|
|
2691
|
+
)
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
_BINDING_SELECT = """
|
|
2695
|
+
SELECT
|
|
2696
|
+
b.binding_id,
|
|
2697
|
+
b.scope_key,
|
|
2698
|
+
b.project_alias,
|
|
2699
|
+
b.native_thread_id,
|
|
2700
|
+
b.model_id,
|
|
2701
|
+
b.effort_id,
|
|
2702
|
+
b.service_tier_id,
|
|
2703
|
+
b.settings_revision,
|
|
2704
|
+
b.message_context_mode,
|
|
2705
|
+
b.context_anchor_message_id,
|
|
2706
|
+
b.context_anchor_create_time_ms,
|
|
2707
|
+
b.context_revision,
|
|
2708
|
+
b.task_reactions_enabled,
|
|
2709
|
+
b.progress_card_enabled,
|
|
2710
|
+
b.completion_mention_enabled,
|
|
2711
|
+
b.feedback_revision,
|
|
2712
|
+
b.creator_id,
|
|
2713
|
+
b.created_at,
|
|
2714
|
+
b.activated_at,
|
|
2715
|
+
b.ever_activated,
|
|
2716
|
+
CASE WHEN s.active_binding_id = b.binding_id THEN 1 ELSE 0 END AS active
|
|
2717
|
+
FROM bindings b
|
|
2718
|
+
JOIN scopes s ON s.scope_key = b.scope_key
|
|
2719
|
+
"""
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
_SCOPE_SELECT = """
|
|
2723
|
+
SELECT
|
|
2724
|
+
scope_key, app_id, chat_id, kind, topic_id,
|
|
2725
|
+
active_binding_id, updated_at
|
|
2726
|
+
FROM scopes
|
|
2727
|
+
"""
|
|
2728
|
+
|
|
2729
|
+
|
|
2730
|
+
_PROJECT_SELECT = """
|
|
2731
|
+
SELECT alias, cwd, enabled, revision, created_at, updated_at, deleted
|
|
2732
|
+
FROM projects
|
|
2733
|
+
"""
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
_BINDING_INVENTORY_SELECT = """
|
|
2737
|
+
SELECT
|
|
2738
|
+
b.binding_id,
|
|
2739
|
+
b.scope_key,
|
|
2740
|
+
b.project_alias,
|
|
2741
|
+
b.native_thread_id,
|
|
2742
|
+
b.model_id,
|
|
2743
|
+
b.effort_id,
|
|
2744
|
+
b.service_tier_id,
|
|
2745
|
+
b.settings_revision,
|
|
2746
|
+
b.message_context_mode,
|
|
2747
|
+
b.context_anchor_message_id,
|
|
2748
|
+
b.context_anchor_create_time_ms,
|
|
2749
|
+
b.context_revision,
|
|
2750
|
+
b.task_reactions_enabled,
|
|
2751
|
+
b.progress_card_enabled,
|
|
2752
|
+
b.completion_mention_enabled,
|
|
2753
|
+
b.feedback_revision,
|
|
2754
|
+
b.creator_id,
|
|
2755
|
+
b.created_at,
|
|
2756
|
+
b.activated_at,
|
|
2757
|
+
b.ever_activated,
|
|
2758
|
+
CASE WHEN s.active_binding_id = b.binding_id THEN 1 ELSE 0 END AS active,
|
|
2759
|
+
s.scope_key AS scope_scope_key,
|
|
2760
|
+
s.app_id AS scope_app_id,
|
|
2761
|
+
s.chat_id AS scope_chat_id,
|
|
2762
|
+
s.kind AS scope_kind,
|
|
2763
|
+
s.topic_id AS scope_topic_id,
|
|
2764
|
+
s.active_binding_id AS scope_active_binding_id,
|
|
2765
|
+
s.updated_at AS scope_updated_at
|
|
2766
|
+
FROM bindings b
|
|
2767
|
+
JOIN scopes s ON s.scope_key = b.scope_key
|
|
2768
|
+
"""
|
|
2769
|
+
|
|
2770
|
+
|
|
2771
|
+
_SIDE_INVENTORY_SELECT = """
|
|
2772
|
+
SELECT
|
|
2773
|
+
st.side_id, st.app_id, st.chat_id, st.topic_id, st.root_message_id,
|
|
2774
|
+
st.source_message_id, st.parent_binding_id, st.creator_id,
|
|
2775
|
+
st.requires_mention, st.state, st.created_at, st.updated_at,
|
|
2776
|
+
b.project_alias AS parent_project_alias
|
|
2777
|
+
FROM side_topics st
|
|
2778
|
+
LEFT JOIN bindings b ON b.binding_id = st.parent_binding_id
|
|
2779
|
+
"""
|
|
2780
|
+
|
|
2781
|
+
|
|
2782
|
+
_PROJECT_AGGREGATE_SELECT = """
|
|
2783
|
+
SELECT
|
|
2784
|
+
p.alias, p.cwd, p.enabled, p.revision, p.created_at, p.updated_at, p.deleted,
|
|
2785
|
+
COUNT(b.binding_id) AS binding_count,
|
|
2786
|
+
COALESCE(SUM(CASE WHEN b.binding_id IS NOT NULL
|
|
2787
|
+
AND b.native_thread_id IS NULL THEN 1 ELSE 0 END), 0)
|
|
2788
|
+
AS lazy_binding_count,
|
|
2789
|
+
COALESCE(SUM(CASE WHEN b.native_thread_id IS NOT NULL THEN 1 ELSE 0 END), 0)
|
|
2790
|
+
AS materialized_binding_count,
|
|
2791
|
+
MAX(CASE WHEN b.ever_activated = 1 THEN b.activated_at END)
|
|
2792
|
+
AS last_activated_at
|
|
2793
|
+
FROM projects p
|
|
2794
|
+
LEFT JOIN bindings b ON b.project_alias = p.alias
|
|
2795
|
+
"""
|
|
2796
|
+
|
|
2797
|
+
|
|
2798
|
+
_SIDE_TOPIC_SELECT = """
|
|
2799
|
+
SELECT
|
|
2800
|
+
side_id, app_id, chat_id, topic_id, root_message_id,
|
|
2801
|
+
source_message_id, parent_binding_id, creator_id,
|
|
2802
|
+
requires_mention, state, created_at, updated_at
|
|
2803
|
+
FROM side_topics
|
|
2804
|
+
"""
|
|
2805
|
+
|
|
2806
|
+
|
|
2807
|
+
def _binding(row: sqlite3.Row) -> ThreadBinding:
|
|
2808
|
+
settings_values = (
|
|
2809
|
+
row["model_id"],
|
|
2810
|
+
row["effort_id"],
|
|
2811
|
+
row["service_tier_id"],
|
|
2812
|
+
)
|
|
2813
|
+
if all(value is None for value in settings_values):
|
|
2814
|
+
settings = None
|
|
2815
|
+
elif all(isinstance(value, str) and value for value in settings_values):
|
|
2816
|
+
settings = BindingTurnSettings(*settings_values)
|
|
2817
|
+
else:
|
|
2818
|
+
raise RuntimeError("Binding contains partial Turn settings")
|
|
2819
|
+
settings_revision = row["settings_revision"]
|
|
2820
|
+
if not isinstance(settings_revision, int) or settings_revision < 1:
|
|
2821
|
+
raise RuntimeError("Binding contains an invalid settings revision")
|
|
2822
|
+
message_context_mode = _mention_context_mode(row["message_context_mode"])
|
|
2823
|
+
context_anchor = _message_context_anchor(
|
|
2824
|
+
row["context_anchor_message_id"],
|
|
2825
|
+
row["context_anchor_create_time_ms"],
|
|
2826
|
+
)
|
|
2827
|
+
if (
|
|
2828
|
+
message_context_mode is MentionContextMode.CURRENT_ONLY
|
|
2829
|
+
and context_anchor is not None
|
|
2830
|
+
) or (
|
|
2831
|
+
message_context_mode is MentionContextMode.CATCH_UP
|
|
2832
|
+
and context_anchor is None
|
|
2833
|
+
):
|
|
2834
|
+
raise RuntimeError("Binding contains inconsistent mention context")
|
|
2835
|
+
context_revision = row["context_revision"]
|
|
2836
|
+
if not isinstance(context_revision, int) or context_revision < 1:
|
|
2837
|
+
raise RuntimeError("Binding contains an invalid context revision")
|
|
2838
|
+
# Schema v7 retains the historical task_reactions_enabled storage name;
|
|
2839
|
+
# ADR 0051 narrows that value to the optional THINKING reaction pulse.
|
|
2840
|
+
feedback_values = (
|
|
2841
|
+
row["task_reactions_enabled"],
|
|
2842
|
+
row["progress_card_enabled"],
|
|
2843
|
+
row["completion_mention_enabled"],
|
|
2844
|
+
)
|
|
2845
|
+
if not all(
|
|
2846
|
+
isinstance(value, int) and value in {0, 1}
|
|
2847
|
+
for value in feedback_values
|
|
2848
|
+
):
|
|
2849
|
+
raise RuntimeError("Binding contains invalid task feedback")
|
|
2850
|
+
task_feedback = BindingTaskFeedback(
|
|
2851
|
+
reaction_pulse_enabled=bool(feedback_values[0]),
|
|
2852
|
+
progress_card_enabled=bool(feedback_values[1]),
|
|
2853
|
+
completion_mention_enabled=bool(feedback_values[2]),
|
|
2854
|
+
)
|
|
2855
|
+
feedback_revision = row["feedback_revision"]
|
|
2856
|
+
if not isinstance(feedback_revision, int) or feedback_revision < 1:
|
|
2857
|
+
raise RuntimeError("Binding contains an invalid feedback revision")
|
|
2858
|
+
ever_activated = row["ever_activated"]
|
|
2859
|
+
if not isinstance(ever_activated, int) or ever_activated not in {0, 1}:
|
|
2860
|
+
raise RuntimeError("Binding contains an invalid activation flag")
|
|
2861
|
+
return ThreadBinding(
|
|
2862
|
+
id=row["binding_id"],
|
|
2863
|
+
scope_key=row["scope_key"],
|
|
2864
|
+
project_alias=row["project_alias"],
|
|
2865
|
+
native_thread_id=row["native_thread_id"],
|
|
2866
|
+
turn_settings=settings,
|
|
2867
|
+
settings_revision=settings_revision,
|
|
2868
|
+
creator_id=row["creator_id"],
|
|
2869
|
+
active=bool(row["active"]),
|
|
2870
|
+
created_at=row["created_at"],
|
|
2871
|
+
activated_at=(row["activated_at"] if ever_activated else None),
|
|
2872
|
+
message_context_mode=message_context_mode,
|
|
2873
|
+
context_anchor=context_anchor,
|
|
2874
|
+
context_revision=context_revision,
|
|
2875
|
+
task_feedback=task_feedback,
|
|
2876
|
+
feedback_revision=feedback_revision,
|
|
2877
|
+
)
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
def _scope_record(row: sqlite3.Row, *, prefix: str = "") -> ScopeRecord:
|
|
2881
|
+
return ScopeRecord(
|
|
2882
|
+
scope_key=row[prefix + "scope_key"],
|
|
2883
|
+
app_id=row[prefix + "app_id"],
|
|
2884
|
+
chat_id=row[prefix + "chat_id"],
|
|
2885
|
+
kind=ScopeKind(row[prefix + "kind"]),
|
|
2886
|
+
topic_id=row[prefix + "topic_id"],
|
|
2887
|
+
active_binding_id=row[prefix + "active_binding_id"],
|
|
2888
|
+
updated_at=row[prefix + "updated_at"],
|
|
2889
|
+
)
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
def _binding_inventory(row: sqlite3.Row) -> BindingInventoryRecord:
|
|
2893
|
+
return BindingInventoryRecord(
|
|
2894
|
+
binding=_binding(row),
|
|
2895
|
+
scope=_scope_record(row, prefix="scope_"),
|
|
2896
|
+
)
|
|
2897
|
+
|
|
2898
|
+
|
|
2899
|
+
def _settings_values(
|
|
2900
|
+
settings: BindingTurnSettings | None,
|
|
2901
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
2902
|
+
if settings is None:
|
|
2903
|
+
return (None, None, None)
|
|
2904
|
+
return (settings.model_id, settings.effort_id, settings.service_tier_id)
|
|
2905
|
+
|
|
2906
|
+
|
|
2907
|
+
def _feedback_values(
|
|
2908
|
+
feedback: BindingTaskFeedback,
|
|
2909
|
+
) -> tuple[bool, bool, bool]:
|
|
2910
|
+
if not isinstance(feedback, BindingTaskFeedback):
|
|
2911
|
+
raise ValueError("task feedback must be a BindingTaskFeedback")
|
|
2912
|
+
return (
|
|
2913
|
+
feedback.reaction_pulse_enabled,
|
|
2914
|
+
feedback.progress_card_enabled,
|
|
2915
|
+
feedback.completion_mention_enabled,
|
|
2916
|
+
)
|
|
2917
|
+
|
|
2918
|
+
|
|
2919
|
+
def _mention_context_mode(value: object) -> MentionContextMode:
|
|
2920
|
+
try:
|
|
2921
|
+
return MentionContextMode(value)
|
|
2922
|
+
except (TypeError, ValueError) as error:
|
|
2923
|
+
raise RuntimeError(
|
|
2924
|
+
"Binding contains an invalid mention context mode"
|
|
2925
|
+
) from error
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def _message_context_anchor(
|
|
2929
|
+
message_id: object,
|
|
2930
|
+
create_time_ms: object,
|
|
2931
|
+
) -> MessageContextAnchor | None:
|
|
2932
|
+
if message_id is None and create_time_ms is None:
|
|
2933
|
+
return None
|
|
2934
|
+
try:
|
|
2935
|
+
return MessageContextAnchor(
|
|
2936
|
+
message_id=message_id, # type: ignore[arg-type]
|
|
2937
|
+
create_time_ms=create_time_ms, # type: ignore[arg-type]
|
|
2938
|
+
)
|
|
2939
|
+
except (TypeError, ValueError) as error:
|
|
2940
|
+
raise RuntimeError("Binding contains an invalid context anchor") from error
|
|
2941
|
+
|
|
2942
|
+
|
|
2943
|
+
def _context_values(
|
|
2944
|
+
anchor: MessageContextAnchor | None,
|
|
2945
|
+
) -> tuple[str | None, int | None]:
|
|
2946
|
+
if anchor is None:
|
|
2947
|
+
return (None, None)
|
|
2948
|
+
if not isinstance(anchor, MessageContextAnchor):
|
|
2949
|
+
raise ValueError("context anchor must be a MessageContextAnchor")
|
|
2950
|
+
return (anchor.message_id, anchor.create_time_ms)
|
|
2951
|
+
|
|
2952
|
+
|
|
2953
|
+
def _validate_context_state(
|
|
2954
|
+
*,
|
|
2955
|
+
scope_kind: ScopeKind,
|
|
2956
|
+
mode: MentionContextMode,
|
|
2957
|
+
anchor: MessageContextAnchor | None,
|
|
2958
|
+
) -> None:
|
|
2959
|
+
if not isinstance(mode, MentionContextMode):
|
|
2960
|
+
raise ValueError("message context mode must be a MentionContextMode")
|
|
2961
|
+
_context_values(anchor)
|
|
2962
|
+
if mode is MentionContextMode.CURRENT_ONLY:
|
|
2963
|
+
if anchor is not None:
|
|
2964
|
+
raise ValueError("current-only context must not have an anchor")
|
|
2965
|
+
return
|
|
2966
|
+
if scope_kind is ScopeKind.DIRECT:
|
|
2967
|
+
raise ValueError("direct Binding cannot use catch-up context")
|
|
2968
|
+
if anchor is None:
|
|
2969
|
+
raise ValueError("catch-up context requires an exact message anchor")
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
def _project_record(row: sqlite3.Row) -> ProjectRecord:
|
|
2973
|
+
return ProjectRecord(
|
|
2974
|
+
alias=row["alias"],
|
|
2975
|
+
cwd=row["cwd"],
|
|
2976
|
+
enabled=bool(row["enabled"]),
|
|
2977
|
+
revision=row["revision"],
|
|
2978
|
+
created_at=row["created_at"],
|
|
2979
|
+
updated_at=row["updated_at"],
|
|
2980
|
+
deleted=bool(row["deleted"]),
|
|
2981
|
+
)
|
|
2982
|
+
|
|
2983
|
+
|
|
2984
|
+
def _project_aggregate(row: sqlite3.Row) -> ProjectAggregate:
|
|
2985
|
+
return ProjectAggregate(
|
|
2986
|
+
project=_project_record(row),
|
|
2987
|
+
binding_count=row["binding_count"],
|
|
2988
|
+
lazy_binding_count=row["lazy_binding_count"],
|
|
2989
|
+
materialized_binding_count=row["materialized_binding_count"],
|
|
2990
|
+
last_activated_at=row["last_activated_at"],
|
|
2991
|
+
)
|
|
2992
|
+
|
|
2993
|
+
|
|
2994
|
+
def _side_topic(row: sqlite3.Row) -> SideTopicRecord:
|
|
2995
|
+
return SideTopicRecord(
|
|
2996
|
+
id=row["side_id"],
|
|
2997
|
+
app_id=row["app_id"],
|
|
2998
|
+
chat_id=row["chat_id"],
|
|
2999
|
+
topic_id=row["topic_id"],
|
|
3000
|
+
root_message_id=row["root_message_id"],
|
|
3001
|
+
source_message_id=row["source_message_id"],
|
|
3002
|
+
parent_binding_id=row["parent_binding_id"],
|
|
3003
|
+
creator_id=row["creator_id"],
|
|
3004
|
+
requires_mention=bool(row["requires_mention"]),
|
|
3005
|
+
state=SideTopicState(row["state"]),
|
|
3006
|
+
created_at=row["created_at"],
|
|
3007
|
+
updated_at=row["updated_at"],
|
|
3008
|
+
)
|
|
3009
|
+
|
|
3010
|
+
|
|
3011
|
+
def _side_inventory(row: sqlite3.Row) -> SideTopicInventoryRecord:
|
|
3012
|
+
return SideTopicInventoryRecord(
|
|
3013
|
+
side_topic=_side_topic(row),
|
|
3014
|
+
project_alias=row["parent_project_alias"],
|
|
3015
|
+
)
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
def _binding_inventory_statement(
|
|
3019
|
+
*,
|
|
3020
|
+
query: BindingQuery,
|
|
3021
|
+
cursor: BindingCursor | None,
|
|
3022
|
+
limit: int,
|
|
3023
|
+
) -> tuple[str, list[object]]:
|
|
3024
|
+
clauses: list[str] = []
|
|
3025
|
+
parameters: list[object] = []
|
|
3026
|
+
if query.app_id is not None:
|
|
3027
|
+
_require_query_value("App ID", query.app_id)
|
|
3028
|
+
clauses.append("s.app_id = ?")
|
|
3029
|
+
parameters.append(query.app_id)
|
|
3030
|
+
if query.search is not None:
|
|
3031
|
+
_require_query_value("Binding search", query.search)
|
|
3032
|
+
fields = ("b.binding_id", "b.project_alias", "s.chat_id", "s.topic_id")
|
|
3033
|
+
clauses.append("(" + " OR ".join(f"instr(lower({field}), lower(?)) > 0" for field in fields) + ")")
|
|
3034
|
+
parameters.extend(query.search for _ in fields)
|
|
3035
|
+
if query.project_alias is not None:
|
|
3036
|
+
_require_query_value("Project alias", query.project_alias)
|
|
3037
|
+
clauses.append("b.project_alias = ?")
|
|
3038
|
+
parameters.append(query.project_alias)
|
|
3039
|
+
if query.scope_kind is not None:
|
|
3040
|
+
if not isinstance(query.scope_kind, ScopeKind):
|
|
3041
|
+
raise ValueError("Scope kind is invalid")
|
|
3042
|
+
clauses.append("s.kind = ?")
|
|
3043
|
+
parameters.append(query.scope_kind.value)
|
|
3044
|
+
if query.project_aliases is not None:
|
|
3045
|
+
if not query.project_aliases:
|
|
3046
|
+
raise ValueError("Project alias filter must not be empty")
|
|
3047
|
+
for alias in query.project_aliases:
|
|
3048
|
+
_require_query_value("Project alias", alias)
|
|
3049
|
+
clauses.append(
|
|
3050
|
+
"b.project_alias IN (" + ",".join("?" for _ in query.project_aliases) + ")"
|
|
3051
|
+
)
|
|
3052
|
+
parameters.extend(query.project_aliases)
|
|
3053
|
+
if query.scope_kinds is not None:
|
|
3054
|
+
if not query.scope_kinds or any(
|
|
3055
|
+
not isinstance(kind, ScopeKind) for kind in query.scope_kinds
|
|
3056
|
+
):
|
|
3057
|
+
raise ValueError("Scope kinds filter is invalid")
|
|
3058
|
+
clauses.append(
|
|
3059
|
+
"s.kind IN (" + ",".join("?" for _ in query.scope_kinds) + ")"
|
|
3060
|
+
)
|
|
3061
|
+
parameters.extend(kind.value for kind in query.scope_kinds)
|
|
3062
|
+
if query.chat_id is not None:
|
|
3063
|
+
_require_query_value("chat ID", query.chat_id)
|
|
3064
|
+
clauses.append("s.chat_id = ?")
|
|
3065
|
+
parameters.append(query.chat_id)
|
|
3066
|
+
if query.topic_id is not None:
|
|
3067
|
+
_require_query_value("topic ID", query.topic_id)
|
|
3068
|
+
clauses.append("s.topic_id = ?")
|
|
3069
|
+
parameters.append(query.topic_id)
|
|
3070
|
+
if query.identity is not None:
|
|
3071
|
+
_require_query_value("Binding identity", query.identity)
|
|
3072
|
+
clauses.append("(b.binding_id = ? OR b.native_thread_id = ?)")
|
|
3073
|
+
parameters.extend((query.identity, query.identity))
|
|
3074
|
+
if query.materialized is not None:
|
|
3075
|
+
if not isinstance(query.materialized, bool):
|
|
3076
|
+
raise ValueError("materialized filter must be boolean")
|
|
3077
|
+
clauses.append(
|
|
3078
|
+
"b.native_thread_id IS NOT NULL"
|
|
3079
|
+
if query.materialized
|
|
3080
|
+
else "b.native_thread_id IS NULL"
|
|
3081
|
+
)
|
|
3082
|
+
if query.current is not None:
|
|
3083
|
+
if not isinstance(query.current, bool):
|
|
3084
|
+
raise ValueError("current filter must be boolean")
|
|
3085
|
+
clauses.append(
|
|
3086
|
+
"s.active_binding_id = b.binding_id"
|
|
3087
|
+
if query.current
|
|
3088
|
+
else "(s.active_binding_id IS NULL OR s.active_binding_id != b.binding_id)"
|
|
3089
|
+
)
|
|
3090
|
+
if query.created_from is not None:
|
|
3091
|
+
_require_query_value("created-from time", query.created_from)
|
|
3092
|
+
clauses.append("b.created_at >= ?")
|
|
3093
|
+
parameters.append(query.created_from)
|
|
3094
|
+
if query.created_before is not None:
|
|
3095
|
+
_require_query_value("created-before time", query.created_before)
|
|
3096
|
+
clauses.append("b.created_at < ?")
|
|
3097
|
+
parameters.append(query.created_before)
|
|
3098
|
+
if cursor is not None:
|
|
3099
|
+
clauses.append(
|
|
3100
|
+
"(b.created_at < ? OR (b.created_at = ? AND b.binding_id < ?))"
|
|
3101
|
+
)
|
|
3102
|
+
parameters.extend((cursor.created_at, cursor.created_at, cursor.binding_id))
|
|
3103
|
+
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
|
3104
|
+
parameters.append(limit)
|
|
3105
|
+
return (
|
|
3106
|
+
_BINDING_INVENTORY_SELECT
|
|
3107
|
+
+ where
|
|
3108
|
+
+ " ORDER BY b.created_at DESC, b.binding_id DESC LIMIT ?",
|
|
3109
|
+
parameters,
|
|
3110
|
+
)
|
|
3111
|
+
|
|
3112
|
+
|
|
3113
|
+
def _side_inventory_statement(
|
|
3114
|
+
*,
|
|
3115
|
+
query: SideTopicQuery,
|
|
3116
|
+
cursor: SideTopicCursor | None,
|
|
3117
|
+
limit: int,
|
|
3118
|
+
) -> tuple[str, list[object]]:
|
|
3119
|
+
clauses: list[str] = []
|
|
3120
|
+
parameters: list[object] = []
|
|
3121
|
+
for label, column, value in (
|
|
3122
|
+
("Project alias", "b.project_alias", query.project_alias),
|
|
3123
|
+
("parent Binding ID", "st.parent_binding_id", query.parent_binding_id),
|
|
3124
|
+
("app ID", "st.app_id", query.app_id),
|
|
3125
|
+
("chat ID", "st.chat_id", query.chat_id),
|
|
3126
|
+
("topic ID", "st.topic_id", query.topic_id),
|
|
3127
|
+
("root message ID", "st.root_message_id", query.root_message_id),
|
|
3128
|
+
):
|
|
3129
|
+
if value is not None:
|
|
3130
|
+
_require_query_value(label, value)
|
|
3131
|
+
clauses.append(column + " = ?")
|
|
3132
|
+
parameters.append(value)
|
|
3133
|
+
if query.state is not None:
|
|
3134
|
+
if not isinstance(query.state, SideTopicState):
|
|
3135
|
+
raise ValueError("Side Topic state is invalid")
|
|
3136
|
+
clauses.append("st.state = ?")
|
|
3137
|
+
parameters.append(query.state.value)
|
|
3138
|
+
if query.created_from is not None:
|
|
3139
|
+
_require_query_value("created-from time", query.created_from)
|
|
3140
|
+
clauses.append("st.created_at >= ?")
|
|
3141
|
+
parameters.append(query.created_from)
|
|
3142
|
+
if query.created_before is not None:
|
|
3143
|
+
_require_query_value("created-before time", query.created_before)
|
|
3144
|
+
clauses.append("st.created_at < ?")
|
|
3145
|
+
parameters.append(query.created_before)
|
|
3146
|
+
if cursor is not None:
|
|
3147
|
+
clauses.append(
|
|
3148
|
+
"(st.created_at < ? OR (st.created_at = ? AND st.side_id < ?))"
|
|
3149
|
+
)
|
|
3150
|
+
parameters.extend((cursor.created_at, cursor.created_at, cursor.side_id))
|
|
3151
|
+
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
|
3152
|
+
parameters.append(limit)
|
|
3153
|
+
return (
|
|
3154
|
+
_SIDE_INVENTORY_SELECT
|
|
3155
|
+
+ where
|
|
3156
|
+
+ " ORDER BY st.created_at DESC, st.side_id DESC LIMIT ?",
|
|
3157
|
+
parameters,
|
|
3158
|
+
)
|
|
3159
|
+
|
|
3160
|
+
|
|
3161
|
+
def _validate_page_limit(limit: int, *, maximum: int = 50) -> int:
|
|
3162
|
+
if (
|
|
3163
|
+
isinstance(limit, bool)
|
|
3164
|
+
or not isinstance(limit, int)
|
|
3165
|
+
or not 1 <= limit <= maximum
|
|
3166
|
+
):
|
|
3167
|
+
raise ValueError(f"page size must be between 1 and {maximum}")
|
|
3168
|
+
return limit
|
|
3169
|
+
|
|
3170
|
+
|
|
3171
|
+
def _require_query_value(label: str, value: str) -> None:
|
|
3172
|
+
if not isinstance(value, str) or not value:
|
|
3173
|
+
raise ValueError(f"{label} must not be empty")
|
|
3174
|
+
|
|
3175
|
+
|
|
3176
|
+
def _require_exact_scope(row: sqlite3.Row, scope: FeishuScope) -> None:
|
|
3177
|
+
actual = (
|
|
3178
|
+
row["scope_key"],
|
|
3179
|
+
row["app_id"],
|
|
3180
|
+
row["chat_id"],
|
|
3181
|
+
row["kind"],
|
|
3182
|
+
row["topic_id"],
|
|
3183
|
+
)
|
|
3184
|
+
expected = (
|
|
3185
|
+
scope.key,
|
|
3186
|
+
scope.app_id,
|
|
3187
|
+
scope.chat_id,
|
|
3188
|
+
scope.kind.value,
|
|
3189
|
+
scope.topic_id,
|
|
3190
|
+
)
|
|
3191
|
+
if actual != expected:
|
|
3192
|
+
raise ScopeConflict("Scope key is already bound to different identity")
|
|
3193
|
+
|
|
3194
|
+
|
|
3195
|
+
def _require_same_side_reservation(
|
|
3196
|
+
record: SideTopicRecord,
|
|
3197
|
+
*,
|
|
3198
|
+
chat_id: str,
|
|
3199
|
+
parent_binding_id: str,
|
|
3200
|
+
creator_id: str,
|
|
3201
|
+
requires_mention: bool,
|
|
3202
|
+
) -> None:
|
|
3203
|
+
if (
|
|
3204
|
+
record.chat_id != chat_id
|
|
3205
|
+
or record.parent_binding_id != parent_binding_id
|
|
3206
|
+
or record.creator_id != creator_id
|
|
3207
|
+
or record.requires_mention is not requires_mention
|
|
3208
|
+
):
|
|
3209
|
+
raise SideTopicConflict(
|
|
3210
|
+
"Side source message is already reserved with different identity"
|
|
3211
|
+
)
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
def _now() -> str:
|
|
3215
|
+
return datetime.now(UTC).isoformat(timespec="microseconds")
|