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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -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")