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,2187 @@
1
+ """Typed application boundary shared by Feishu and the Admin control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections import Counter
8
+ from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
9
+ from dataclasses import dataclass, replace
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Any, NoReturn
13
+
14
+ from .blocking_io import BoundedBlockingIOExecutor, BlockingIOExecutorSaturated
15
+ from .chat_labels import ChatLabel, ChatLabelProvider, ChatLabelResolver
16
+ from .coordination import ScopeCoordinator
17
+ from .updates import UpdateService
18
+ from ..bindings import (
19
+ BindingCursor,
20
+ BindingInventoryRecord,
21
+ BindingNotFound,
22
+ BindingPage,
23
+ BindingQuery,
24
+ BindingStore,
25
+ BindingTaskFeedback,
26
+ BindingTurnSettings,
27
+ ProjectAggregate,
28
+ ProjectAggregatePage,
29
+ ProjectDeleteSnapshot,
30
+ ProjectDisabled as StoredProjectDisabled,
31
+ ProjectNotFound as StoredProjectNotFound,
32
+ ProjectRevisionConflict as StoredProjectRevisionConflict,
33
+ ScopeRecord,
34
+ SideTopicCursor,
35
+ SideTopicConflict,
36
+ SideTopicInventoryRecord,
37
+ SideTopicPage,
38
+ SideTopicQuery,
39
+ SideTopicRecord,
40
+ SideTopicState,
41
+ ThreadBinding,
42
+ )
43
+ from ..codex_runtime import CodexRuntime
44
+ from ..runtime.contracts import (
45
+ ActiveTurnSnapshot,
46
+ BindingRuntimeSnapshot,
47
+ NativeThreadCatalog,
48
+ NativeThreadCatalogState,
49
+ NativeThreadMetadata,
50
+ ReleaseDisposition,
51
+ SideLifecycleOutcome,
52
+ SideCloseFailed,
53
+ SideSessionSnapshot,
54
+ SideSessionNotFound,
55
+ StopAcknowledger,
56
+ StopDisposition,
57
+ ThreadSubscriptionState,
58
+ ThreadArchived,
59
+ ThreadCatalogIdentityMissing,
60
+ ThreadDeleteTargetChanged,
61
+ ThreadDeleteUnavailable,
62
+ ThreadLifecycleError,
63
+ ThreadLifecycleStateUnknown,
64
+ )
65
+ from ..domain import (
66
+ FeishuScope,
67
+ GoalStatus,
68
+ MessageContextAnchor,
69
+ MentionContextMode,
70
+ NativeCapability,
71
+ SESSION_IDLE_STATE,
72
+ session_stop_available,
73
+ )
74
+ from ..projects import (
75
+ Project,
76
+ ProjectDisabled,
77
+ ProjectRegistry,
78
+ StaleProject,
79
+ UnknownProject,
80
+ )
81
+ from ..sdk_gap_adapter import GoalControlError, GoalSnapshot
82
+ from ..schedules.service import ScheduleService
83
+ from ..defaults.service import SessionDefaultsService
84
+ from ..model_settings import ModelCatalog
85
+
86
+
87
+ _BINDING_STATUS_RESOLUTION_CONCURRENCY = 8
88
+ _NATIVE_INDEX_READ_SECONDS = 3.0
89
+ _SESSION_SUMMARY_READ_CONCURRENCY = 4
90
+ _PROJECT_DELETE_SECONDS = 120.0
91
+ _PROJECT_DELETE_STEP_SECONDS = 30.0
92
+ logger = logging.getLogger(__name__)
93
+
94
+
95
+ class ManagementError(RuntimeError):
96
+ """Base class for stable application-boundary precondition failures."""
97
+
98
+
99
+ class NoCurrentBinding(ManagementError):
100
+ pass
101
+
102
+
103
+ class CurrentBindingChanged(ManagementError):
104
+ pass
105
+
106
+
107
+ class BindingScopeMismatch(ManagementError):
108
+ pass
109
+
110
+
111
+ class ActivePointerChanged(ManagementError):
112
+ pass
113
+
114
+
115
+ class SideIdentityMismatch(ManagementError):
116
+ pass
117
+
118
+
119
+ class NativeThreadMissing(ManagementError):
120
+ pass
121
+
122
+
123
+ class NativeCatalogInconsistent(ManagementError):
124
+ pass
125
+
126
+
127
+ class RuntimeStateChanged(ManagementError):
128
+ pass
129
+
130
+
131
+ class SidePublicationInProgress(ManagementError):
132
+ pass
133
+
134
+
135
+ class SchedulePublicationInProgress(ManagementError):
136
+ pass
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class NativeThreadView:
141
+ # None means the management catalog could not classify a materialized Thread.
142
+ # An absent NativeThreadView, rather than an absent state, represents Lazy.
143
+ state: NativeThreadCatalogState | None
144
+ metadata: NativeThreadMetadata | None
145
+
146
+
147
+ def classify_native_thread_view(
148
+ thread_id: str | None,
149
+ *,
150
+ active: Mapping[str, NativeThreadMetadata],
151
+ archived: Mapping[str, NativeThreadMetadata],
152
+ ) -> NativeThreadView | None:
153
+ """Classify raw catalog membership, preferring archived on overlap.
154
+
155
+ Inventory readers resolve overlap to Unknown before calling this helper;
156
+ raw catalog absence alone does not establish inventory Missing.
157
+ """
158
+
159
+ if thread_id is None:
160
+ return None
161
+ archived_metadata = archived.get(thread_id)
162
+ if archived_metadata is not None:
163
+ return NativeThreadView(
164
+ NativeThreadCatalogState.ARCHIVED,
165
+ archived_metadata,
166
+ )
167
+ active_metadata = active.get(thread_id)
168
+ if active_metadata is not None:
169
+ return NativeThreadView(NativeThreadCatalogState.ACTIVE, active_metadata)
170
+ return NativeThreadView(NativeThreadCatalogState.MISSING, None)
171
+
172
+
173
+ class BindingPrimaryStatusResolution(str, Enum):
174
+ LOCAL = "local"
175
+ ARCHIVED = "archived"
176
+ RESOLVED = "resolved"
177
+ DEFERRED = "deferred"
178
+ UNAVAILABLE = "unavailable"
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class BindingStatusProjection:
183
+ snapshot: BindingRuntimeSnapshot
184
+ primary_status: str | None
185
+ primary_status_resolution: BindingPrimaryStatusResolution
186
+ persisted_goal_status: GoalStatus | None
187
+ subscription_state: ThreadSubscriptionState | None
188
+ pointer_state: str
189
+ catalog_state: NativeThreadCatalogState | None
190
+ can_stop: bool
191
+ can_release: bool
192
+
193
+ @property
194
+ def binding_id(self) -> str:
195
+ return self.snapshot.binding_id
196
+
197
+
198
+ def _project_binding_status(
199
+ *,
200
+ binding: ThreadBinding,
201
+ snapshot: BindingRuntimeSnapshot,
202
+ persisted_goal: GoalSnapshot | None = None,
203
+ persisted_goal_resolved: bool = False,
204
+ primary_status_unavailable: bool = False,
205
+ catalog_state: NativeThreadCatalogState | None = None,
206
+ ) -> BindingStatusProjection:
207
+ """Project one ordinary Binding without letting deferred Goal reads mean idle."""
208
+
209
+ if snapshot.binding_id != binding.id:
210
+ raise ValueError("Binding status projection identity mismatch")
211
+ persisted_status = (
212
+ persisted_goal.status if persisted_goal is not None else None
213
+ )
214
+ if snapshot.lifecycle is not None:
215
+ primary_status = snapshot.lifecycle.state.value
216
+ resolution = BindingPrimaryStatusResolution.LOCAL
217
+ elif snapshot.turn is not None:
218
+ primary_status = snapshot.turn.state.value
219
+ resolution = BindingPrimaryStatusResolution.LOCAL
220
+ elif snapshot.compacting:
221
+ primary_status = "compacting"
222
+ resolution = BindingPrimaryStatusResolution.LOCAL
223
+ elif snapshot.goal is not None:
224
+ primary_status = snapshot.goal.state.value
225
+ resolution = BindingPrimaryStatusResolution.LOCAL
226
+ elif catalog_state is NativeThreadCatalogState.ARCHIVED:
227
+ primary_status = None
228
+ resolution = BindingPrimaryStatusResolution.ARCHIVED
229
+ elif primary_status_unavailable:
230
+ primary_status = None
231
+ resolution = BindingPrimaryStatusResolution.UNAVAILABLE
232
+ elif persisted_goal_resolved:
233
+ primary_status = (
234
+ SESSION_IDLE_STATE
235
+ if persisted_status is None
236
+ else f"goal-{persisted_status.value}"
237
+ )
238
+ resolution = BindingPrimaryStatusResolution.RESOLVED
239
+ else:
240
+ primary_status = None
241
+ resolution = BindingPrimaryStatusResolution.DEFERRED
242
+ subscription_state = (
243
+ snapshot.subscription.state
244
+ if snapshot.subscription is not None
245
+ else None
246
+ )
247
+ can_release = (
248
+ binding.native_thread_id is not None
249
+ and resolution is BindingPrimaryStatusResolution.RESOLVED
250
+ and subscription_state
251
+ in {
252
+ ThreadSubscriptionState.SUBSCRIBED,
253
+ ThreadSubscriptionState.RELEASE_PENDING,
254
+ }
255
+ and snapshot.lifecycle is None
256
+ and snapshot.turn is None
257
+ and not snapshot.compacting
258
+ and snapshot.goal is None
259
+ and persisted_status is not GoalStatus.ACTIVE
260
+ )
261
+ return BindingStatusProjection(
262
+ snapshot=snapshot,
263
+ primary_status=primary_status,
264
+ primary_status_resolution=resolution,
265
+ persisted_goal_status=persisted_status,
266
+ subscription_state=subscription_state,
267
+ pointer_state="current" if binding.active else "inactive",
268
+ catalog_state=catalog_state,
269
+ can_stop=session_stop_available(primary_status),
270
+ can_release=can_release,
271
+ )
272
+
273
+
274
+ class SessionInventoryState(str, Enum):
275
+ ACTIVE = "active"
276
+ LAZY = "lazy"
277
+ ARCHIVED = "archived"
278
+ MISSING = "missing"
279
+ UNKNOWN = "unknown"
280
+
281
+
282
+ @dataclass(frozen=True, slots=True)
283
+ class SessionQuery:
284
+ local: BindingQuery = BindingQuery()
285
+ inventory_states: tuple[SessionInventoryState, ...] | None = None
286
+
287
+
288
+ def _session_inventory_state(
289
+ binding: ThreadBinding, native: NativeThreadView | None,
290
+ ) -> SessionInventoryState:
291
+ if binding.native_thread_id is None:
292
+ return SessionInventoryState.LAZY
293
+ if native is None or native.state is None:
294
+ return SessionInventoryState.UNKNOWN
295
+ return SessionInventoryState(native.state.value)
296
+
297
+
298
+ @dataclass(frozen=True, slots=True)
299
+ class SessionInventoryItem:
300
+ record: BindingInventoryRecord
301
+ native: NativeThreadView | None
302
+ chat: ChatLabel
303
+
304
+ @property
305
+ def inventory_state(self) -> SessionInventoryState:
306
+ return _session_inventory_state(self.record.binding, self.native)
307
+
308
+
309
+ @dataclass(frozen=True, slots=True)
310
+ class SessionInventoryPage:
311
+ items: tuple[SessionInventoryItem, ...]
312
+ next_cursor: BindingCursor | None
313
+ catalog_available: bool = True
314
+
315
+
316
+ @dataclass(frozen=True, slots=True)
317
+ class ScopeSessionPage:
318
+ items: tuple[SessionInventoryItem, ...]
319
+ total_count: int
320
+ page: int
321
+ active_binding_id: str | None
322
+ catalog_available: bool
323
+ unconfirmed_count: int
324
+
325
+
326
+ @dataclass(frozen=True, slots=True)
327
+ class SideTopicInventoryItem:
328
+ record: SideTopicInventoryRecord
329
+ chat: ChatLabel
330
+ runtime: SideSessionSnapshot | None
331
+
332
+
333
+ @dataclass(frozen=True, slots=True)
334
+ class SideTopicInventoryPage:
335
+ items: tuple[SideTopicInventoryItem, ...]
336
+ next_cursor: SideTopicCursor | None
337
+
338
+
339
+ @dataclass(frozen=True, slots=True)
340
+ class ProjectInventoryItem:
341
+ aggregate: ProjectAggregate
342
+ archived_binding_count: int | None
343
+ deleting: bool = False
344
+ unconfirmed_binding_count: int = 0
345
+
346
+
347
+ @dataclass(frozen=True, slots=True)
348
+ class ProjectInventoryPage:
349
+ items: tuple[ProjectInventoryItem, ...]
350
+ next_cursor: str | None
351
+
352
+
353
+ @dataclass(frozen=True, slots=True)
354
+ class ProjectDeletionResult:
355
+ project_alias: str
356
+ deleted: bool
357
+ deleted_session_count: int
358
+ remaining_sessions: tuple[ThreadBinding, ...]
359
+ remaining_side_count: int
360
+ failed_binding_id: str | None = None
361
+ failed_side_id: str | None = None
362
+ code: str = "deleted"
363
+ deleted_plan_count: int = 0
364
+ remaining_scheduled_run_count: int = 0
365
+
366
+
367
+ @dataclass(frozen=True, slots=True)
368
+ class RuntimeSnapshots:
369
+ bindings: tuple[BindingStatusProjection, ...]
370
+ sides: tuple[SideSessionSnapshot, ...]
371
+ missing_side_ids: tuple[str, ...]
372
+
373
+
374
+ @dataclass(frozen=True, slots=True)
375
+ class RuntimePrecondition:
376
+ activity_revision: int
377
+ physical_turn_id: str | None
378
+
379
+ def __post_init__(self) -> None:
380
+ if self.activity_revision < 0:
381
+ raise ValueError("runtime activity revision must be non-negative")
382
+
383
+
384
+ @dataclass(frozen=True, slots=True)
385
+ class CurrentBindingTarget:
386
+ scope_key: str
387
+ binding_id: str
388
+
389
+ def __post_init__(self) -> None:
390
+ if not self.scope_key or not self.binding_id:
391
+ raise ValueError("current Binding target identity must not be empty")
392
+
393
+
394
+ @dataclass(frozen=True, slots=True)
395
+ class ExactBindingTarget:
396
+ scope_key: str
397
+ binding_id: str
398
+ expected_active_binding_id: str | None
399
+
400
+ def __post_init__(self) -> None:
401
+ if not self.scope_key or not self.binding_id:
402
+ raise ValueError("exact Binding target identity must not be empty")
403
+
404
+
405
+ @dataclass(frozen=True, slots=True)
406
+ class CurrentSideTarget:
407
+ side_id: str
408
+ app_id: str
409
+ chat_id: str
410
+ topic_id: str | None
411
+ root_message_id: str | None
412
+
413
+ def __post_init__(self) -> None:
414
+ if not self.side_id or not self.app_id or not self.chat_id:
415
+ raise ValueError("Side target identity must not be empty")
416
+
417
+
418
+ @dataclass(frozen=True, slots=True)
419
+ class CreatedBinding:
420
+ # Reusing an existing Binding leaves Project resolution to ordinary input.
421
+ project: Project | None
422
+ binding: ThreadBinding
423
+ created: bool = True
424
+
425
+
426
+ @dataclass(frozen=True, slots=True)
427
+ class RenamedBinding:
428
+ binding: ThreadBinding
429
+ name: str
430
+
431
+
432
+ @dataclass(frozen=True, slots=True)
433
+ class ReleasedBinding:
434
+ binding: ThreadBinding
435
+ disposition: ReleaseDisposition
436
+
437
+
438
+ @dataclass(frozen=True, slots=True)
439
+ class StoppedBinding:
440
+ binding: ThreadBinding
441
+ disposition: StopDisposition
442
+
443
+
444
+ @dataclass(frozen=True, slots=True)
445
+ class ClosedSide:
446
+ record: SideTopicRecord
447
+ outcome: SideLifecycleOutcome | None
448
+ missing_runtime_session: bool = False
449
+
450
+
451
+ class ManagementRuntimePort:
452
+ """Concrete capability wrapper exposing only approved management seams.
453
+
454
+ Keeping this as a real object, rather than merely a structural Protocol,
455
+ prevents a future HTTP handler from accidentally receiving the complete
456
+ Runtime and reaching Prompt, Turn, Goal, Compact, or Side-create
457
+ capabilities. Native delete is exposed only as one exact Binding
458
+ lifecycle operation; adapters still decide which product surfaces may use
459
+ it.
460
+ """
461
+
462
+ __slots__ = ("__runtime",)
463
+
464
+ def __init__(self, runtime: CodexRuntime) -> None:
465
+ self.__runtime = runtime
466
+
467
+ async def read_scheduled_turn(
468
+ self, binding_id: str, turn_id: str, *, deadline: float | None = None,
469
+ ) -> str:
470
+ return await self.__runtime.read_scheduled_turn(binding_id, turn_id, deadline=deadline)
471
+
472
+ @property
473
+ def native_delete_available(self) -> bool:
474
+ return NativeCapability.DELETE in self.__runtime.available_capabilities
475
+
476
+ async def configure_exact(
477
+ self,
478
+ *,
479
+ binding_id: str,
480
+ expected_revision: int,
481
+ settings: BindingTurnSettings | None,
482
+ ) -> ThreadBinding:
483
+ return await self.__runtime.configure_exact(
484
+ binding_id=binding_id,
485
+ expected_revision=expected_revision,
486
+ settings=settings,
487
+ )
488
+
489
+ async def configure_context_exact(
490
+ self,
491
+ *,
492
+ binding_id: str,
493
+ expected_settings_revision: int,
494
+ expected_context_revision: int,
495
+ expected_feedback_revision: int,
496
+ settings: BindingTurnSettings | None,
497
+ task_feedback: BindingTaskFeedback,
498
+ message_context_mode: MentionContextMode,
499
+ context_anchor: MessageContextAnchor | None,
500
+ ) -> ThreadBinding:
501
+ return await self.__runtime.configure_context_exact(
502
+ binding_id=binding_id,
503
+ expected_settings_revision=expected_settings_revision,
504
+ expected_context_revision=expected_context_revision,
505
+ expected_feedback_revision=expected_feedback_revision,
506
+ settings=settings,
507
+ task_feedback=task_feedback,
508
+ message_context_mode=message_context_mode,
509
+ context_anchor=context_anchor,
510
+ )
511
+
512
+ async def activate_exact(
513
+ self,
514
+ binding_id: str,
515
+ *,
516
+ context_anchor: MessageContextAnchor | None = None,
517
+ ) -> ThreadBinding:
518
+ return await self.__runtime.activate_exact(
519
+ binding_id,
520
+ context_anchor=context_anchor,
521
+ )
522
+
523
+ async def model_catalog(self) -> ModelCatalog:
524
+ return await self.__runtime.model_catalog()
525
+
526
+ async def resolve_turn_settings(
527
+ self,
528
+ *,
529
+ model_id: str,
530
+ effort_id: str,
531
+ service_tier_id: str,
532
+ ) -> BindingTurnSettings:
533
+ resolved = await self.__runtime.resolve_model_settings(
534
+ model_id=model_id,
535
+ effort_id=effort_id,
536
+ service_tier_id=service_tier_id,
537
+ )
538
+ return BindingTurnSettings(
539
+ model_id=resolved.model_id,
540
+ effort_id=resolved.effort_id,
541
+ service_tier_id=resolved.service_tier_id,
542
+ )
543
+
544
+ async def rename_exact(self, binding_id: str, name: str) -> str:
545
+ return await self.__runtime.rename_exact(binding_id, name)
546
+
547
+ async def archive_exact(self, binding_id: str) -> ThreadBinding:
548
+ return await self.__runtime.archive_exact(binding_id)
549
+
550
+ async def restore_exact(self, binding_id: str) -> ThreadBinding:
551
+ return await self.__runtime.restore_exact(binding_id)
552
+
553
+ async def restore_as_current_exact(
554
+ self,
555
+ binding_id: str,
556
+ *,
557
+ context_anchor: MessageContextAnchor | None = None,
558
+ ) -> ThreadBinding:
559
+ return await self.__runtime.restore_as_current_exact(
560
+ binding_id,
561
+ context_anchor=context_anchor,
562
+ )
563
+
564
+ async def delete_lazy_exact(self, binding_id: str) -> ThreadBinding:
565
+ return await self.__runtime.delete_lazy_exact(binding_id)
566
+
567
+ async def delete_exact(
568
+ self,
569
+ binding_id: str,
570
+ *,
571
+ expected_native_thread_id: str | None,
572
+ ) -> ThreadBinding:
573
+ return await self.__runtime.delete_exact(
574
+ binding_id,
575
+ expected_native_thread_id=expected_native_thread_id,
576
+ )
577
+
578
+ async def delete_archived_exact(
579
+ self,
580
+ binding_id: str,
581
+ *,
582
+ expected_native_thread_id: str,
583
+ ) -> ThreadBinding:
584
+ return await self.__runtime.delete_archived_exact(
585
+ binding_id,
586
+ expected_native_thread_id=expected_native_thread_id,
587
+ )
588
+
589
+ async def stop_exact(
590
+ self,
591
+ binding_id: str,
592
+ *,
593
+ acknowledge: StopAcknowledger | None = None,
594
+ expected_activity_revision: int | None = None,
595
+ expected_turn_id: str | None = None,
596
+ ) -> StopDisposition:
597
+ if expected_activity_revision is None:
598
+ return await self.__runtime.stop_exact(
599
+ binding_id,
600
+ acknowledge=acknowledge,
601
+ )
602
+ return await self.__runtime.stop_exact(
603
+ binding_id,
604
+ acknowledge=acknowledge,
605
+ expected_activity_revision=expected_activity_revision,
606
+ expected_turn_id=expected_turn_id,
607
+ )
608
+
609
+ async def recheck_turn_exact(
610
+ self,
611
+ binding_id: str,
612
+ *,
613
+ expected_activity_revision: int,
614
+ expected_turn_id: str,
615
+ ) -> ActiveTurnSnapshot:
616
+ return await self.__runtime.recheck_turn_exact(
617
+ binding_id,
618
+ expected_activity_revision=expected_activity_revision,
619
+ expected_turn_id=expected_turn_id,
620
+ )
621
+
622
+ async def release_exact(self, binding_id: str) -> ReleaseDisposition:
623
+ return await self.__runtime.release_exact(binding_id)
624
+
625
+ async def close_side_exact(
626
+ self,
627
+ side_id: str,
628
+ *,
629
+ state: SideTopicState,
630
+ ) -> SideLifecycleOutcome:
631
+ return await self.__runtime.close_side_exact(side_id, state=state)
632
+
633
+ async def drain_project_side_creation(self, binding_id: str) -> None:
634
+ await self.__runtime.drain_project_side_creation(binding_id)
635
+
636
+ async def binding_pointer_changed(
637
+ self,
638
+ previous_binding_id: str | None,
639
+ current_binding_id: str | None,
640
+ ) -> None:
641
+ await self.__runtime.binding_pointer_changed(
642
+ previous_binding_id,
643
+ current_binding_id,
644
+ )
645
+
646
+ async def is_thread_archived(self, thread_id: str) -> bool:
647
+ return await self.__runtime.thread_is_archived(thread_id)
648
+
649
+ async def thread_catalog_state_exact(
650
+ self,
651
+ thread_id: str,
652
+ ) -> NativeThreadCatalogState:
653
+ return await self.__runtime.thread_catalog_state(thread_id)
654
+
655
+ def runtime_snapshot_exact(self, binding_id: str) -> BindingRuntimeSnapshot:
656
+ return self.__runtime.binding_runtime_snapshot(binding_id)
657
+
658
+ async def goal_snapshot_exact(
659
+ self,
660
+ binding: ThreadBinding,
661
+ ) -> GoalSnapshot | None:
662
+ return await self.__runtime.goal_snapshot(binding)
663
+
664
+ def side_snapshot_exact(self, side_id: str) -> SideSessionSnapshot | None:
665
+ try:
666
+ return self.__runtime.side_snapshot(side_id)
667
+ except SideSessionNotFound:
668
+ return None
669
+
670
+ def project_side_snapshots(self, alias: str) -> tuple[SideSessionSnapshot, ...]:
671
+ return self.__runtime.project_side_snapshots(alias)
672
+
673
+ async def thread_metadata_exact(
674
+ self,
675
+ thread_ids: tuple[str, ...],
676
+ *,
677
+ archived: bool,
678
+ deadline: float,
679
+ use_state_db_only: bool | None = None,
680
+ ) -> dict[str, NativeThreadMetadata]:
681
+ return await self.__runtime.thread_metadata(
682
+ thread_ids,
683
+ archived=archived,
684
+ deadline=deadline,
685
+ max_pages=1_000,
686
+ max_items=100_000,
687
+ use_state_db_only=use_state_db_only,
688
+ )
689
+
690
+ async def thread_summary_exact(
691
+ self, thread_id: str,
692
+ ) -> NativeThreadMetadata:
693
+ return await self.__runtime.thread_summary(thread_id)
694
+
695
+ async def thread_catalog_exact(
696
+ self,
697
+ *,
698
+ archived: bool,
699
+ deadline: float,
700
+ use_state_db_only: bool | None = None,
701
+ ) -> NativeThreadCatalog:
702
+ return await self.__runtime.thread_catalog(
703
+ archived=archived,
704
+ deadline=deadline,
705
+ use_state_db_only=use_state_db_only,
706
+ )
707
+
708
+
709
+ class InstanceManagementService:
710
+ """Coordinate management mutations over the one Store and Runtime."""
711
+
712
+ def __init__(
713
+ self,
714
+ *,
715
+ bindings: BindingStore,
716
+ projects: ProjectRegistry,
717
+ runtime: ManagementRuntimePort,
718
+ scope_coordinator: ScopeCoordinator,
719
+ blocking_io: BoundedBlockingIOExecutor | None = None,
720
+ chat_labels: ChatLabelProvider | None = None,
721
+ updates: UpdateService | None = None,
722
+ root: Path | None = None,
723
+ ) -> None:
724
+ self._bindings = bindings
725
+ self._projects = projects
726
+ self._runtime = runtime
727
+ self._scope_coordinator = scope_coordinator
728
+ self._updates = updates or UpdateService(root=root)
729
+ self._chat_labels = (
730
+ ChatLabelResolver(chat_labels) if chat_labels is not None else None
731
+ )
732
+ self._blocking_io = blocking_io or BoundedBlockingIOExecutor(
733
+ max_workers=1,
734
+ capacity=2,
735
+ )
736
+ self._goal_status_read_limiter = asyncio.Semaphore(
737
+ _BINDING_STATUS_RESOLUTION_CONCURRENCY
738
+ )
739
+ self._summary_read_limiter = asyncio.Semaphore(
740
+ _SESSION_SUMMARY_READ_CONCURRENCY
741
+ )
742
+ self._summary_reads: set[asyncio.Task[NativeThreadMetadata]] = set()
743
+ self._summary_reads_closed = False
744
+ self.schedules: ScheduleService | None = None
745
+ self.defaults: SessionDefaultsService | None = None
746
+ self._schedule_creation_drain: Callable[[str, float], Awaitable[bool]] | None = None
747
+
748
+ def enable_schedules(self, *, app_id: str, chat_info: Any = None) -> ScheduleService:
749
+ if self.schedules is None:
750
+ self.schedules = ScheduleService(
751
+ bindings=self._bindings, runtime=self._runtime,
752
+ app_id=app_id, chat_info=chat_info,
753
+ )
754
+ elif self.schedules.app_id != app_id:
755
+ raise ValueError("schedule management App identity cannot change")
756
+ return self.schedules
757
+
758
+ def set_schedule_creation_drain(
759
+ self, callback: Callable[[str, float], Awaitable[bool]],
760
+ ) -> None:
761
+ self._schedule_creation_drain = callback
762
+
763
+ def enable_defaults(self, *, app_id: str, chat_info: Any = None) -> SessionDefaultsService:
764
+ if self.defaults is None:
765
+ self.defaults = SessionDefaultsService(
766
+ bindings=self._bindings, projects=self._projects,
767
+ runtime=self._runtime, app_id=app_id, chat_info=chat_info,
768
+ blocking_io=self._blocking_io,
769
+ )
770
+ elif self.defaults.app_id != app_id:
771
+ raise ValueError("default configuration App identity cannot change")
772
+ return self.defaults
773
+
774
+ @property
775
+ def scope_coordinator(self) -> ScopeCoordinator:
776
+ return self._scope_coordinator
777
+
778
+ @property
779
+ def native_delete_available(self) -> bool:
780
+ return self._runtime.native_delete_available
781
+
782
+ async def close(self, *, deadline: float | None = None) -> None:
783
+ self._summary_reads_closed = True
784
+ if self.schedules is not None:
785
+ self.schedules.close_admission()
786
+ await self._updates.close(deadline=deadline)
787
+ if self._chat_labels is not None:
788
+ await self._chat_labels.aclose()
789
+ await self._blocking_io.aclose(deadline=deadline)
790
+ if self._summary_reads:
791
+ remaining = (
792
+ 1.0 if deadline is None
793
+ else min(1.0, max(0.0, deadline - asyncio.get_running_loop().time()))
794
+ )
795
+ # Do not cancel SDK wrappers and lose accounting for their workers.
796
+ # Any remaining reads finish when the shared Codex transport closes.
797
+ await asyncio.wait(tuple(self._summary_reads), timeout=remaining)
798
+
799
+ def set_service_ready(self, ready: bool) -> None:
800
+ self._updates.set_service_ready(ready)
801
+
802
+ async def update_status(self) -> dict[str, Any]:
803
+ return await self._updates.status()
804
+
805
+ async def restart_service(self, *, installation_id: str) -> dict[str, Any]:
806
+ return await self._updates.restart(installation_id=installation_id)
807
+
808
+ async def register_project(
809
+ self,
810
+ *,
811
+ alias: str,
812
+ path: str | None,
813
+ create_directory: bool,
814
+ deadline: float | None = None,
815
+ ) -> Project:
816
+ return await self._blocking_io.submit(
817
+ self._projects.register,
818
+ alias=alias,
819
+ path=path,
820
+ create_directory=create_directory,
821
+ deadline=deadline,
822
+ )
823
+
824
+ async def resolve_new_project(
825
+ self, alias: str, *, deadline: float,
826
+ ) -> Project:
827
+ """Validate a creation cwd without blocking the shared event loop."""
828
+
829
+ async with asyncio.timeout_at(deadline):
830
+ while True:
831
+ try:
832
+ return await self._blocking_io.submit(
833
+ self._projects.resolve_for_new,
834
+ alias,
835
+ deadline=deadline,
836
+ )
837
+ except BlockingIOExecutorSaturated:
838
+ # Several plans commonly share a due minute. Wait within
839
+ # this short creation budget for existing filesystem I/O;
840
+ # no native execution is queued or concurrency-limited.
841
+ await self._blocking_io.drain(deadline=deadline)
842
+
843
+ async def set_project_enabled(
844
+ self,
845
+ *,
846
+ alias: str,
847
+ enabled: bool,
848
+ expected_revision: int,
849
+ ) -> Project:
850
+ return self._projects.set_enabled(
851
+ alias=alias,
852
+ enabled=enabled,
853
+ expected_revision=expected_revision,
854
+ )
855
+
856
+ async def query_project_options(
857
+ self,
858
+ *,
859
+ cursor: str | None = None,
860
+ limit: int = 25,
861
+ deadline: float,
862
+ ) -> ProjectAggregatePage:
863
+ """Read registered Projects without native catalog hydration."""
864
+
865
+ return await self._bindings.query_project_aggregates(
866
+ cursor=cursor,
867
+ limit=limit,
868
+ deadline_seconds=self._query_seconds(deadline),
869
+ )
870
+
871
+ async def query_projects(
872
+ self,
873
+ *,
874
+ cursor: str | None = None,
875
+ limit: int = 25,
876
+ deadline: float,
877
+ ) -> ProjectInventoryPage:
878
+ page = await self._bindings.query_project_aggregates(
879
+ cursor=cursor,
880
+ limit=limit,
881
+ deadline_seconds=self._query_seconds(deadline),
882
+ )
883
+ native = (
884
+ await self._indexed_native_views(deadline=deadline)
885
+ if any(item.materialized_binding_count for item in page.items)
886
+ else {}
887
+ )
888
+ confirmed = {
889
+ thread_id: view for thread_id, view in (native or {}).items()
890
+ if view.state is not None
891
+ }
892
+ try:
893
+ project_by_thread = await self._bindings.project_aliases_for_native_threads(
894
+ tuple(confirmed), expected_projects=page.items,
895
+ deadline_seconds=self._query_seconds(deadline),
896
+ )
897
+ except Exception as error:
898
+ logger.warning("project catalog counts unavailable error_type=%s", type(error).__name__)
899
+ native = None
900
+ project_by_thread = {}
901
+ known_counts = Counter(project_by_thread.values())
902
+ archived_counts = Counter(
903
+ alias for thread_id, alias in project_by_thread.items()
904
+ if confirmed[thread_id].state is NativeThreadCatalogState.ARCHIVED
905
+ )
906
+ return ProjectInventoryPage(
907
+ items=tuple(
908
+ ProjectInventoryItem(
909
+ aggregate=item,
910
+ archived_binding_count=(
911
+ archived_counts[item.project.alias]
912
+ if native is not None or item.materialized_binding_count == 0
913
+ else None
914
+ ),
915
+ deleting=self._bindings.project_delete_in_progress(item.project.alias),
916
+ unconfirmed_binding_count=max(
917
+ 0, item.materialized_binding_count - known_counts[item.project.alias],
918
+ ),
919
+ )
920
+ for item in page.items
921
+ ),
922
+ next_cursor=page.next_cursor,
923
+ )
924
+
925
+ async def preview_project_delete(
926
+ self,
927
+ *,
928
+ alias: str,
929
+ expected_revision: int,
930
+ deadline: float,
931
+ ) -> ProjectDeleteSnapshot:
932
+ snapshot = await self._blocking_io.submit(
933
+ self._projects.preview_delete, alias,
934
+ extra_side_ids=self._project_side_ids(alias), deadline=deadline,
935
+ )
936
+ if snapshot.project.revision != expected_revision:
937
+ raise StaleProject("Project 已改变,请刷新后重新确认。")
938
+ self._require_project_delete_capability(snapshot)
939
+ return snapshot
940
+
941
+ def _require_project_delete_capability(self, snapshot: ProjectDeleteSnapshot) -> None:
942
+ if not self.native_delete_available and any(
943
+ item.binding.native_thread_id is not None for item in snapshot.bindings
944
+ ):
945
+ raise ThreadDeleteUnavailable("原生会话删除能力不可用,Project 未删除。")
946
+
947
+ def _project_side_ids(self, alias: str) -> tuple[str, ...]:
948
+ return tuple(side.side_id for side in self._runtime.project_side_snapshots(alias))
949
+
950
+ async def delete_project(
951
+ self,
952
+ *,
953
+ alias: str,
954
+ expected_revision: int,
955
+ expected_inventory_fingerprint: str,
956
+ deadline: float | None = None,
957
+ ) -> ProjectDeletionResult:
958
+ """Delete one confirmed inventory through the existing exact operations.
959
+
960
+ Only short Registry transactions reserve admission. Native operations
961
+ hold their own Binding/Side lifecycle slots; neither a Project execution
962
+ lock nor a durable batch job is introduced. Partial work is never rolled
963
+ back or automatically retried.
964
+ """
965
+ loop = asyncio.get_running_loop()
966
+ deadline = min(
967
+ deadline if deadline is not None else float("inf"),
968
+ loop.time() + _PROJECT_DELETE_SECONDS,
969
+ )
970
+ if deadline <= loop.time():
971
+ raise TimeoutError("Project deletion deadline elapsed")
972
+ extra_side_ids = self._project_side_ids(alias)
973
+ self._require_project_delete_capability(self._projects.preview_delete(
974
+ alias, extra_side_ids=extra_side_ids,
975
+ ))
976
+ snapshot = self._projects.begin_delete(
977
+ alias=alias,
978
+ expected_revision=expected_revision,
979
+ expected_inventory_fingerprint=expected_inventory_fingerprint,
980
+ extra_side_ids=extra_side_ids,
981
+ )
982
+ failed_binding_id: str | None = None
983
+ failed_side_id: str | None = None
984
+ code = "deleted"
985
+ deleted = False
986
+ try:
987
+ if self._schedule_creation_drain is not None:
988
+ drained = await self._schedule_creation_drain(
989
+ alias, min(deadline, loop.time() + _PROJECT_DELETE_STEP_SECONDS),
990
+ )
991
+ if not drained:
992
+ raise SchedulePublicationInProgress(alias)
993
+ # Frozen plans cannot issue another claim. A publication with no
994
+ # complete native identity remains in the exact remaining inventory;
995
+ # do not pretend deleting its definition undid an external call.
996
+ if any(
997
+ run.phase in {"claimed", "publishing_topic", "binding_ready", "starting_turn"}
998
+ or run.error_code == "publishing_unknown"
999
+ for run in self._bindings.schedules.project_pending_runs(alias)
1000
+ ):
1001
+ raise SchedulePublicationInProgress(alias)
1002
+ bindings_by_id = {item.binding.id: item.binding for item in snapshot.bindings}
1003
+ parent_ids = tuple(dict.fromkeys([
1004
+ *bindings_by_id,
1005
+ *(side.parent_binding_id for side in snapshot.sides),
1006
+ ]))
1007
+ for parent_id in parent_ids:
1008
+ binding = bindings_by_id.get(parent_id)
1009
+ failed_binding_id = binding.id if binding is not None else None
1010
+ failed_side_id = None
1011
+ sides = tuple(
1012
+ side for side in snapshot.sides if side.parent_binding_id == parent_id
1013
+ )
1014
+ if sides:
1015
+ if loop.time() >= deadline:
1016
+ raise TimeoutError("Project deletion deadline elapsed")
1017
+ async with asyncio.timeout_at(min(deadline, loop.time() + _PROJECT_DELETE_STEP_SECONDS)):
1018
+ await self._runtime.drain_project_side_creation(parent_id)
1019
+ for side in sides:
1020
+ failed_side_id = side.id
1021
+ if self._bindings.get_side_topic(side.id).state is SideTopicState.CREATING:
1022
+ # Native setup may be done while Feishu is still
1023
+ # publishing the root/topic. Closing now could
1024
+ # strand a late message without its route tombstone.
1025
+ raise SidePublicationInProgress(side.id)
1026
+ if loop.time() >= deadline:
1027
+ raise TimeoutError("Project deletion deadline elapsed")
1028
+ async with asyncio.timeout_at(min(deadline, loop.time() + _PROJECT_DELETE_STEP_SECONDS)):
1029
+ closed = await self.close_side(target=CurrentSideTarget(
1030
+ side_id=side.id,
1031
+ app_id=side.app_id,
1032
+ chat_id=side.chat_id,
1033
+ topic_id=side.topic_id,
1034
+ root_message_id=side.root_message_id,
1035
+ ))
1036
+ if (
1037
+ not closed.record.state.terminal
1038
+ or (closed.outcome is not None and closed.outcome.error is not None)
1039
+ or self._runtime.side_snapshot_exact(side.id) is not None
1040
+ ):
1041
+ raise SideCloseFailed("关联 Side 的关闭尚未确认。")
1042
+ failed_side_id = None
1043
+ if binding is None:
1044
+ continue
1045
+ if loop.time() >= deadline:
1046
+ raise TimeoutError("Project deletion deadline elapsed")
1047
+ try:
1048
+ async with asyncio.timeout_at(min(deadline, loop.time() + _PROJECT_DELETE_STEP_SECONDS)):
1049
+ await self.delete_exact_binding(
1050
+ target=ExactBindingTarget(binding.scope_key, binding.id, None),
1051
+ expected_native_thread_id=binding.native_thread_id,
1052
+ )
1053
+ except BindingNotFound:
1054
+ # Another exact deletion may have completed since preview.
1055
+ # Only absence of this same local identity can be skipped.
1056
+ try:
1057
+ self._bindings.get(binding.id)
1058
+ except BindingNotFound:
1059
+ pass
1060
+ else:
1061
+ raise
1062
+ failed_binding_id = None
1063
+ if self._runtime.project_side_snapshots(alias):
1064
+ raise SideCloseFailed("Project 仍有未完成关闭的 Side。")
1065
+ self._projects.finish_delete(
1066
+ alias=alias,
1067
+ expected_revision=snapshot.project.revision,
1068
+ expected_inventory_fingerprint=snapshot.fingerprint,
1069
+ )
1070
+ deleted = True
1071
+ except Exception as error:
1072
+ logger.warning(
1073
+ "project_delete_incomplete project_alias=%s binding_id=%s side_id=%s error_type=%s",
1074
+ alias, failed_binding_id, failed_side_id, type(error).__name__,
1075
+ )
1076
+ if isinstance(error, ThreadLifecycleStateUnknown):
1077
+ code = "outcome_unknown"
1078
+ elif isinstance(error, TimeoutError):
1079
+ code = "deadline_exceeded"
1080
+ elif isinstance(error, (ThreadDeleteTargetChanged, SideIdentityMismatch)):
1081
+ code = "inventory_changed"
1082
+ elif isinstance(error, SideCloseFailed):
1083
+ code = "side_close_failed"
1084
+ elif isinstance(error, SidePublicationInProgress):
1085
+ code = "side_creation_in_progress"
1086
+ elif isinstance(error, SchedulePublicationInProgress):
1087
+ code = "schedule_creation_in_progress"
1088
+ else:
1089
+ code = "delete_failed"
1090
+ finally:
1091
+ # Cancellation or process shutdown leaves a disabled Project. A
1092
+ # new explicit confirmation reads fresh identities; no loop resumes.
1093
+ self._projects.release_delete(
1094
+ alias=alias, expected_revision=snapshot.project.revision,
1095
+ )
1096
+ remaining = self._project_delete_remaining(snapshot, alias)
1097
+ remaining_side_count = sum(
1098
+ not self._bindings.get_side_topic(side.id).state.terminal
1099
+ or self._runtime.side_snapshot_exact(side.id) is not None
1100
+ for side in snapshot.sides
1101
+ )
1102
+ return ProjectDeletionResult(
1103
+ alias, deleted, len(snapshot.bindings) - len(remaining), remaining,
1104
+ remaining_side_count, failed_binding_id, failed_side_id, code,
1105
+ len(snapshot.scheduled_plans),
1106
+ len(self._bindings.schedules.project_pending_runs(alias)),
1107
+ )
1108
+
1109
+ def _project_delete_remaining(
1110
+ self, snapshot: ProjectDeleteSnapshot, alias: str,
1111
+ ) -> tuple[ThreadBinding, ...]:
1112
+ remaining = []
1113
+ for item in snapshot.bindings:
1114
+ try:
1115
+ binding = self._bindings.get(item.binding.id)
1116
+ except BindingNotFound:
1117
+ continue
1118
+ if binding.project_alias == alias:
1119
+ remaining.append(binding)
1120
+ return tuple(remaining)
1121
+
1122
+ async def query_sessions(
1123
+ self,
1124
+ *,
1125
+ query: SessionQuery = SessionQuery(),
1126
+ cursor: BindingCursor | None = None,
1127
+ limit: int = 20,
1128
+ deadline: float,
1129
+ ) -> SessionInventoryPage:
1130
+ if (
1131
+ isinstance(limit, bool)
1132
+ or not isinstance(limit, int)
1133
+ or not 1 <= limit <= 100
1134
+ ):
1135
+ raise ValueError("page size must be between 1 and 100")
1136
+ if query.inventory_states is not None and (
1137
+ not isinstance(query.inventory_states, tuple)
1138
+ or not query.inventory_states
1139
+ or any(
1140
+ not isinstance(state, SessionInventoryState)
1141
+ for state in query.inventory_states
1142
+ )
1143
+ ):
1144
+ raise ValueError("Session inventory states filter is invalid")
1145
+ states = (
1146
+ frozenset(query.inventory_states)
1147
+ if query.inventory_states is not None
1148
+ else frozenset(SessionInventoryState)
1149
+ )
1150
+ if states == frozenset(SessionInventoryState):
1151
+ page = await self._bindings.query_bindings(
1152
+ query=query.local,
1153
+ cursor=cursor,
1154
+ limit=limit,
1155
+ deadline_seconds=self._query_seconds(deadline),
1156
+ )
1157
+ thread_ids = tuple(dict.fromkeys(
1158
+ record.binding.native_thread_id for record in page.items
1159
+ if record.binding.native_thread_id is not None
1160
+ ))
1161
+ native = await self._indexed_native_views(
1162
+ thread_ids=thread_ids, deadline=deadline,
1163
+ )
1164
+ return await self._session_page(
1165
+ page.items,
1166
+ native=native,
1167
+ next_cursor=page.next_cursor,
1168
+ deadline=deadline,
1169
+ )
1170
+
1171
+ if states == {SessionInventoryState.LAZY}:
1172
+ page = await self._bindings.query_bindings(
1173
+ query=replace(query.local, materialized=False),
1174
+ cursor=cursor,
1175
+ limit=limit,
1176
+ deadline_seconds=self._query_seconds(deadline),
1177
+ )
1178
+ return await self._session_page(
1179
+ page.items,
1180
+ native={},
1181
+ next_cursor=page.next_cursor,
1182
+ deadline=deadline,
1183
+ )
1184
+
1185
+ local = replace(
1186
+ query.local,
1187
+ materialized=None if SessionInventoryState.LAZY in states else True,
1188
+ )
1189
+ native = await self._indexed_native_views(deadline=deadline)
1190
+ selected: list[BindingInventoryRecord] = []
1191
+ scan_cursor = cursor
1192
+ next_cursor: BindingCursor | None = None
1193
+ exhausted = False
1194
+ while len(selected) < limit and not exhausted:
1195
+ page = await self._bindings.query_bindings(
1196
+ query=local,
1197
+ cursor=scan_cursor,
1198
+ limit=50,
1199
+ deadline_seconds=self._query_seconds(deadline),
1200
+ )
1201
+ if not page.items:
1202
+ break
1203
+ for index, record in enumerate(page.items):
1204
+ binding = record.binding
1205
+ scan_cursor = BindingCursor(binding.created_at, binding.id)
1206
+ view = (native or {}).get(binding.native_thread_id)
1207
+ state = _session_inventory_state(binding, view)
1208
+ if state not in states:
1209
+ continue
1210
+ selected.append(record)
1211
+ if len(selected) == limit:
1212
+ has_more_local = (
1213
+ index + 1 < len(page.items) or page.next_cursor is not None
1214
+ )
1215
+ next_cursor = scan_cursor if has_more_local else None
1216
+ break
1217
+ else:
1218
+ if page.next_cursor is None:
1219
+ exhausted = True
1220
+ else:
1221
+ scan_cursor = page.next_cursor
1222
+ continue
1223
+ break
1224
+
1225
+ return await self._session_page(
1226
+ tuple(selected),
1227
+ native=native,
1228
+ next_cursor=next_cursor,
1229
+ deadline=deadline,
1230
+ )
1231
+
1232
+ async def query_scope_sessions(
1233
+ self,
1234
+ *,
1235
+ scope: FeishuScope,
1236
+ archived: bool = False,
1237
+ page: int = 0,
1238
+ limit: int | None = 10,
1239
+ deadline: float,
1240
+ ) -> ScopeSessionPage:
1241
+ """Use the shared inventory for a Scope's stateless numbered cards.
1242
+
1243
+ Preserve activation order, current-first ordering and page clamping.
1244
+ The existing archived card is unpaged (limit=None); confirmed archived
1245
+ rows already have metadata and never need Goal or summary enrichment.
1246
+ """
1247
+ if limit is None:
1248
+ if not archived or page != 0:
1249
+ raise ValueError("only the archived list can be unpaged")
1250
+ elif isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100:
1251
+ raise ValueError("page size must be between 1 and 100")
1252
+ self._query_seconds(deadline)
1253
+ bindings = self._bindings.list_bindings(scope.key)
1254
+ thread_ids = tuple(dict.fromkeys(
1255
+ binding.native_thread_id for binding in bindings
1256
+ if binding.native_thread_id is not None
1257
+ ))
1258
+ native = await self._indexed_native_views(thread_ids=thread_ids, deadline=deadline)
1259
+ if archived and native is None:
1260
+ raise ThreadLifecycleError("无法读取 Codex 归档会话列表,请稍后重试。")
1261
+ selected = []
1262
+ unconfirmed_count = 0
1263
+ for binding in bindings:
1264
+ state = _session_inventory_state(binding, (native or {}).get(binding.native_thread_id))
1265
+ unconfirmed_count += state is SessionInventoryState.UNKNOWN
1266
+ if (state is SessionInventoryState.ARCHIVED) == archived:
1267
+ selected.append(binding)
1268
+ if not archived:
1269
+ selected.sort(key=lambda binding: not binding.active)
1270
+ total_count = len(selected)
1271
+ active_binding_id = next((binding.id for binding in bindings if binding.active), None)
1272
+ if limit is not None:
1273
+ page = max(0, min(page, max(0, (total_count - 1) // limit)))
1274
+ selected = selected[page * limit:(page + 1) * limit]
1275
+ views = await self._session_native_views(selected, native=native, deadline=deadline)
1276
+ scope_record = self._bindings.get_scope(scope.key) if bindings else None
1277
+ return ScopeSessionPage(
1278
+ items=tuple(
1279
+ SessionInventoryItem(
1280
+ record=BindingInventoryRecord(binding, scope_record),
1281
+ native=views.get(binding.native_thread_id),
1282
+ chat=ChatLabelResolver.fallback(scope.chat_id),
1283
+ )
1284
+ for binding in selected
1285
+ ),
1286
+ total_count=total_count,
1287
+ page=page,
1288
+ active_binding_id=active_binding_id,
1289
+ catalog_available=native is not None,
1290
+ unconfirmed_count=unconfirmed_count,
1291
+ )
1292
+
1293
+ async def query_side_topics(
1294
+ self,
1295
+ *,
1296
+ query: SideTopicQuery = SideTopicQuery(),
1297
+ cursor: SideTopicCursor | None = None,
1298
+ limit: int = 25,
1299
+ deadline: float,
1300
+ ) -> SideTopicInventoryPage:
1301
+ page = await self._bindings.query_side_topics(
1302
+ query=query,
1303
+ cursor=cursor,
1304
+ limit=limit,
1305
+ deadline_seconds=self._query_seconds(deadline),
1306
+ )
1307
+ labels = await self.resolve_chat_labels(
1308
+ (item.side_topic.chat_id for item in page.items),
1309
+ deadline=deadline,
1310
+ )
1311
+ return SideTopicInventoryPage(
1312
+ items=tuple(
1313
+ SideTopicInventoryItem(
1314
+ record=item,
1315
+ chat=labels[item.side_topic.chat_id],
1316
+ runtime=self._runtime.side_snapshot_exact(item.side_topic.id),
1317
+ )
1318
+ for item in page.items
1319
+ ),
1320
+ next_cursor=page.next_cursor,
1321
+ )
1322
+
1323
+ async def binding_status_exact(
1324
+ self,
1325
+ binding_id: str,
1326
+ *,
1327
+ snapshot: BindingRuntimeSnapshot | None = None,
1328
+ catalog_state: NativeThreadCatalogState | None = None,
1329
+ deadline: float | None = None,
1330
+ ) -> BindingStatusProjection:
1331
+ binding = self._bindings.get(binding_id)
1332
+ before = snapshot or self._runtime.runtime_snapshot_exact(binding.id)
1333
+ local = _project_binding_status(
1334
+ binding=binding,
1335
+ snapshot=before,
1336
+ catalog_state=catalog_state,
1337
+ )
1338
+ if local.primary_status_resolution in {
1339
+ BindingPrimaryStatusResolution.LOCAL,
1340
+ BindingPrimaryStatusResolution.ARCHIVED,
1341
+ }:
1342
+ return local
1343
+ if (
1344
+ binding.native_thread_id is None
1345
+ or catalog_state is NativeThreadCatalogState.MISSING
1346
+ ):
1347
+ return _project_binding_status(
1348
+ binding=binding,
1349
+ snapshot=before,
1350
+ persisted_goal_resolved=True,
1351
+ catalog_state=catalog_state,
1352
+ )
1353
+ try:
1354
+ async with self._goal_status_read_limiter:
1355
+ if deadline is not None and asyncio.get_running_loop().time() >= deadline:
1356
+ raise TimeoutError("management query deadline elapsed")
1357
+ persisted_goal = await self._runtime.goal_snapshot_exact(binding)
1358
+ except asyncio.CancelledError:
1359
+ raise
1360
+ except (GoalControlError, ThreadLifecycleError):
1361
+ after = self._runtime.runtime_snapshot_exact(binding.id)
1362
+ local_after = _project_binding_status(
1363
+ binding=binding,
1364
+ snapshot=after,
1365
+ catalog_state=catalog_state,
1366
+ )
1367
+ if (
1368
+ local_after.primary_status_resolution
1369
+ is BindingPrimaryStatusResolution.LOCAL
1370
+ ):
1371
+ return local_after
1372
+ raise
1373
+ after = self._runtime.runtime_snapshot_exact(binding.id)
1374
+ return _project_binding_status(
1375
+ binding=binding,
1376
+ snapshot=after,
1377
+ persisted_goal=persisted_goal,
1378
+ persisted_goal_resolved=True,
1379
+ catalog_state=catalog_state,
1380
+ )
1381
+
1382
+ async def binding_statuses_exact(
1383
+ self,
1384
+ *,
1385
+ binding_ids: Sequence[str],
1386
+ catalog_states: Mapping[
1387
+ str, NativeThreadCatalogState | None
1388
+ ] | None = None,
1389
+ deadline: float | None = None,
1390
+ ) -> tuple[BindingStatusProjection, ...]:
1391
+ binding_ids = self._validated_snapshot_ids(binding_ids, "Binding")
1392
+ if len(binding_ids) > 50:
1393
+ raise ValueError("Binding status request accepts at most 50 IDs")
1394
+ resolved_catalog_states = catalog_states or {}
1395
+ if not resolved_catalog_states.keys() <= set(binding_ids):
1396
+ raise ValueError("catalog states must target requested Bindings")
1397
+ async def resolve(binding_id: str) -> BindingStatusProjection:
1398
+ catalog_state = resolved_catalog_states.get(binding_id)
1399
+ try:
1400
+ if deadline is None:
1401
+ return await self.binding_status_exact(
1402
+ binding_id,
1403
+ catalog_state=catalog_state,
1404
+ )
1405
+ async with asyncio.timeout_at(deadline):
1406
+ return await self.binding_status_exact(
1407
+ binding_id,
1408
+ catalog_state=catalog_state,
1409
+ deadline=deadline,
1410
+ )
1411
+ except asyncio.CancelledError:
1412
+ raise
1413
+ except (GoalControlError, ThreadLifecycleError, TimeoutError):
1414
+ binding = self._bindings.get(binding_id)
1415
+ snapshot = self._runtime.runtime_snapshot_exact(binding_id)
1416
+ return _project_binding_status(
1417
+ binding=binding,
1418
+ snapshot=snapshot,
1419
+ primary_status_unavailable=True,
1420
+ catalog_state=catalog_state,
1421
+ )
1422
+
1423
+ return tuple(await asyncio.gather(*(resolve(item) for item in binding_ids)))
1424
+
1425
+ def runtime_snapshots(
1426
+ self,
1427
+ *,
1428
+ binding_ids: Sequence[str] = (),
1429
+ side_ids: Sequence[str] = (),
1430
+ ) -> RuntimeSnapshots:
1431
+ binding_ids = self._validated_snapshot_ids(binding_ids, "Binding")
1432
+ side_ids = self._validated_snapshot_ids(side_ids, "Side")
1433
+ if len(binding_ids) + len(side_ids) > 50:
1434
+ raise ValueError("runtime snapshot request accepts at most 50 IDs")
1435
+ binding_snapshots = []
1436
+ for binding_id in binding_ids:
1437
+ binding = self._bindings.get(binding_id)
1438
+ binding_snapshots.append(
1439
+ _project_binding_status(
1440
+ binding=binding,
1441
+ snapshot=self._runtime.runtime_snapshot_exact(binding.id),
1442
+ )
1443
+ )
1444
+ sides: list[SideSessionSnapshot] = []
1445
+ missing: list[str] = []
1446
+ for side_id in side_ids:
1447
+ snapshot = self._runtime.side_snapshot_exact(side_id)
1448
+ if snapshot is None:
1449
+ missing.append(side_id)
1450
+ else:
1451
+ sides.append(snapshot)
1452
+ return RuntimeSnapshots(
1453
+ bindings=tuple(binding_snapshots),
1454
+ sides=tuple(sides),
1455
+ missing_side_ids=tuple(missing),
1456
+ )
1457
+
1458
+ async def create_current_binding(
1459
+ self,
1460
+ *,
1461
+ scope: FeishuScope,
1462
+ creator_id: str,
1463
+ project_alias: str,
1464
+ expected_project_revision: int | None = None,
1465
+ turn_settings: BindingTurnSettings | None = None,
1466
+ task_feedback: BindingTaskFeedback = BindingTaskFeedback(),
1467
+ message_context_mode: MentionContextMode = MentionContextMode.CURRENT_ONLY,
1468
+ context_anchor: MessageContextAnchor | None = None,
1469
+ deadline: float | None = None,
1470
+ only_if_empty: bool = False,
1471
+ ) -> CreatedBinding:
1472
+ project = await self._blocking_io.submit(
1473
+ self._projects.resolve_for_new,
1474
+ project_alias,
1475
+ expected_revision=expected_project_revision,
1476
+ deadline=deadline,
1477
+ )
1478
+ async with self._scope_coordinator.hold(scope.key):
1479
+ previous_id = self._active_id(scope.key)
1480
+ if only_if_empty and previous_id is not None:
1481
+ return CreatedBinding(
1482
+ project=None, binding=self._bindings.get(previous_id), created=False,
1483
+ )
1484
+ try:
1485
+ binding = self._bindings.create_channel_binding(
1486
+ scope=scope,
1487
+ project_alias=project.alias,
1488
+ creator_id=creator_id,
1489
+ expected_project_revision=(
1490
+ project.revision if only_if_empty else expected_project_revision
1491
+ ),
1492
+ turn_settings=turn_settings,
1493
+ task_feedback=task_feedback,
1494
+ message_context_mode=message_context_mode,
1495
+ context_anchor=context_anchor,
1496
+ )
1497
+ except (
1498
+ StoredProjectNotFound,
1499
+ StoredProjectDisabled,
1500
+ StoredProjectRevisionConflict,
1501
+ ) as error:
1502
+ self._raise_project_create_error(project_alias, error)
1503
+ await self._runtime.binding_pointer_changed(previous_id, binding.id)
1504
+ return CreatedBinding(project=project, binding=binding)
1505
+
1506
+ async def resolve_turn_settings(
1507
+ self,
1508
+ *,
1509
+ model_id: str,
1510
+ effort_id: str,
1511
+ service_tier_id: str,
1512
+ ) -> BindingTurnSettings:
1513
+ return await self._runtime.resolve_turn_settings(
1514
+ model_id=model_id,
1515
+ effort_id=effort_id,
1516
+ service_tier_id=service_tier_id,
1517
+ )
1518
+
1519
+ async def create_exact_lazy_binding(
1520
+ self,
1521
+ *,
1522
+ scope_key: str,
1523
+ project_alias: str,
1524
+ expected_project_revision: int,
1525
+ expected_active_binding_id: str | None,
1526
+ activate: bool,
1527
+ turn_settings: BindingTurnSettings | None = None,
1528
+ deadline: float | None = None,
1529
+ ) -> CreatedBinding:
1530
+ project = await self._blocking_io.submit(
1531
+ self._projects.resolve_for_new,
1532
+ project_alias,
1533
+ expected_revision=expected_project_revision,
1534
+ deadline=deadline,
1535
+ )
1536
+ async with self._scope_coordinator.hold(scope_key):
1537
+ scope = self._bindings.get_scope(scope_key).scope
1538
+ previous_id = self._active_id(scope_key)
1539
+ if previous_id != expected_active_binding_id:
1540
+ raise ActivePointerChanged(
1541
+ "Scope active Binding changed before Lazy creation"
1542
+ )
1543
+ try:
1544
+ binding = self._bindings.create_admin_binding(
1545
+ scope=scope,
1546
+ project_alias=project.alias,
1547
+ expected_project_revision=expected_project_revision,
1548
+ activate=activate,
1549
+ turn_settings=turn_settings,
1550
+ )
1551
+ except (
1552
+ StoredProjectNotFound,
1553
+ StoredProjectDisabled,
1554
+ StoredProjectRevisionConflict,
1555
+ ) as error:
1556
+ self._raise_project_create_error(project_alias, error)
1557
+ if activate:
1558
+ await self._runtime.binding_pointer_changed(previous_id, binding.id)
1559
+ return CreatedBinding(project=project, binding=binding)
1560
+
1561
+ async def resume_current_binding(
1562
+ self,
1563
+ *,
1564
+ scope_key: str,
1565
+ reference: str,
1566
+ context_anchor: MessageContextAnchor | None = None,
1567
+ ) -> ThreadBinding:
1568
+ async with self._scope_coordinator.hold(scope_key):
1569
+ previous_id = self._active_id(scope_key)
1570
+ binding = self._bindings.resolve_reference(
1571
+ scope_key=scope_key,
1572
+ reference=reference,
1573
+ )
1574
+ try:
1575
+ activated = await self._runtime.activate_exact(
1576
+ binding.id,
1577
+ context_anchor=context_anchor,
1578
+ )
1579
+ except ThreadCatalogIdentityMissing as error:
1580
+ raise NativeThreadMissing(
1581
+ "原生会话不在 active 或 archived catalog;本次未设为当前。"
1582
+ ) from error
1583
+ await self._runtime.binding_pointer_changed(previous_id, activated.id)
1584
+ return activated
1585
+
1586
+ async def restore_current_binding(
1587
+ self,
1588
+ *,
1589
+ scope_key: str,
1590
+ reference: str,
1591
+ context_anchor: MessageContextAnchor | None = None,
1592
+ ) -> ThreadBinding:
1593
+ async with self._scope_coordinator.hold(scope_key):
1594
+ previous_id = self._active_id(scope_key)
1595
+ binding = self._bindings.resolve_reference(
1596
+ scope_key=scope_key,
1597
+ reference=reference,
1598
+ )
1599
+ restored = await self._runtime.restore_as_current_exact(
1600
+ binding.id,
1601
+ context_anchor=context_anchor,
1602
+ )
1603
+ await self._runtime.binding_pointer_changed(previous_id, restored.id)
1604
+ return restored
1605
+
1606
+ async def configure_current_binding(
1607
+ self,
1608
+ *,
1609
+ target: CurrentBindingTarget,
1610
+ expected_settings_revision: int,
1611
+ expected_context_revision: int,
1612
+ expected_feedback_revision: int,
1613
+ settings: BindingTurnSettings | None,
1614
+ task_feedback: BindingTaskFeedback,
1615
+ message_context_mode: MentionContextMode,
1616
+ context_anchor: MessageContextAnchor | None,
1617
+ ) -> ThreadBinding:
1618
+ async with self._scope_coordinator.hold(target.scope_key):
1619
+ binding = self._require_current(target)
1620
+ return await self._runtime.configure_context_exact(
1621
+ binding_id=binding.id,
1622
+ expected_settings_revision=expected_settings_revision,
1623
+ expected_context_revision=expected_context_revision,
1624
+ expected_feedback_revision=expected_feedback_revision,
1625
+ settings=settings,
1626
+ task_feedback=task_feedback,
1627
+ message_context_mode=message_context_mode,
1628
+ context_anchor=context_anchor,
1629
+ )
1630
+
1631
+ async def rename_current_binding(
1632
+ self,
1633
+ *,
1634
+ target: CurrentBindingTarget,
1635
+ name: str,
1636
+ ) -> RenamedBinding:
1637
+ async with self._scope_coordinator.hold(target.scope_key):
1638
+ binding = self._require_current(target)
1639
+ # Capture the exact target under Scope coordination, but never hold
1640
+ # that lock while waiting for a previous name writer to finish.
1641
+ normalized = await self._runtime.rename_exact(binding.id, name)
1642
+ return RenamedBinding(binding=binding, name=normalized)
1643
+
1644
+ async def archive_current_binding(
1645
+ self,
1646
+ *,
1647
+ target: CurrentBindingTarget,
1648
+ ) -> ThreadBinding:
1649
+ async with self._scope_coordinator.hold(target.scope_key):
1650
+ binding = self._require_current(target)
1651
+ previous_id = binding.id
1652
+ archived = await self._runtime.archive_exact(binding.id)
1653
+ async with self._scope_coordinator.hold(target.scope_key):
1654
+ current_id = self._active_id(target.scope_key)
1655
+ if previous_id == binding.id and current_id is None:
1656
+ await self._runtime.binding_pointer_changed(binding.id, None)
1657
+ return archived
1658
+
1659
+ async def delete_current_lazy_binding(
1660
+ self,
1661
+ *,
1662
+ target: CurrentBindingTarget,
1663
+ ) -> ThreadBinding:
1664
+ return await self.delete_current_binding(
1665
+ target=target,
1666
+ expected_native_thread_id=None,
1667
+ )
1668
+
1669
+ async def delete_current_binding(
1670
+ self,
1671
+ *,
1672
+ target: CurrentBindingTarget,
1673
+ expected_native_thread_id: str | None,
1674
+ ) -> ThreadBinding:
1675
+ async with self._scope_coordinator.hold(target.scope_key):
1676
+ binding = self._require_current(target)
1677
+ if binding.native_thread_id != expected_native_thread_id:
1678
+ raise CurrentBindingChanged(
1679
+ "the current Binding native Thread changed"
1680
+ )
1681
+ try:
1682
+ deleted = await self._runtime.delete_exact(
1683
+ binding.id,
1684
+ expected_native_thread_id=expected_native_thread_id,
1685
+ )
1686
+ except ThreadDeleteTargetChanged as error:
1687
+ raise CurrentBindingChanged(
1688
+ "the current Binding native Thread changed"
1689
+ ) from error
1690
+ async with self._scope_coordinator.hold(target.scope_key):
1691
+ current_id = self._active_id(target.scope_key)
1692
+ await self._runtime.binding_pointer_changed(binding.id, current_id)
1693
+ return deleted
1694
+
1695
+ async def release_current_binding(
1696
+ self,
1697
+ *,
1698
+ target: CurrentBindingTarget,
1699
+ ) -> ReleasedBinding:
1700
+ async with self._scope_coordinator.hold(target.scope_key):
1701
+ binding = self._require_current(target)
1702
+ disposition = await self._runtime.release_exact(binding.id)
1703
+ return ReleasedBinding(binding=binding, disposition=disposition)
1704
+
1705
+ async def stop_current_binding(
1706
+ self,
1707
+ *,
1708
+ target: CurrentBindingTarget,
1709
+ acknowledge: StopAcknowledger | None = None,
1710
+ runtime_precondition: RuntimePrecondition | None = None,
1711
+ ) -> StoppedBinding:
1712
+ async with self._scope_coordinator.hold(target.scope_key):
1713
+ binding = self._require_current(target)
1714
+ stop_kwargs: dict[str, object] = {"acknowledge": acknowledge}
1715
+ if runtime_precondition is not None:
1716
+ stop_kwargs.update(
1717
+ expected_activity_revision=(
1718
+ runtime_precondition.activity_revision
1719
+ ),
1720
+ expected_turn_id=runtime_precondition.physical_turn_id,
1721
+ )
1722
+ disposition = await self._runtime.stop_exact(
1723
+ binding.id,
1724
+ **stop_kwargs,
1725
+ )
1726
+ return StoppedBinding(binding=binding, disposition=disposition)
1727
+
1728
+ async def activate_exact_binding(
1729
+ self,
1730
+ *,
1731
+ target: ExactBindingTarget,
1732
+ ) -> ThreadBinding:
1733
+ async with self._scope_coordinator.hold(target.scope_key):
1734
+ binding, previous_id = self._require_exact(target)
1735
+ try:
1736
+ activated = await self._runtime.activate_exact(binding.id)
1737
+ except ThreadCatalogIdentityMissing as error:
1738
+ raise NativeThreadMissing(
1739
+ "原生会话不在 active 或 archived catalog;本次未设为当前。"
1740
+ ) from error
1741
+ await self._runtime.binding_pointer_changed(previous_id, activated.id)
1742
+ return activated
1743
+
1744
+ async def configure_exact_binding(
1745
+ self,
1746
+ *,
1747
+ target: ExactBindingTarget,
1748
+ expected_settings_revision: int,
1749
+ settings: BindingTurnSettings | None,
1750
+ ) -> ThreadBinding:
1751
+ async with self._scope_coordinator.hold(target.scope_key):
1752
+ binding, _ = self._require_exact(target)
1753
+ return await self._runtime.configure_exact(
1754
+ binding_id=binding.id,
1755
+ expected_revision=expected_settings_revision,
1756
+ settings=settings,
1757
+ )
1758
+
1759
+ async def rename_exact_binding(
1760
+ self,
1761
+ *,
1762
+ target: ExactBindingTarget,
1763
+ name: str,
1764
+ ) -> RenamedBinding:
1765
+ async with self._scope_coordinator.hold(target.scope_key):
1766
+ binding, _ = self._require_exact(target)
1767
+ normalized = await self._runtime.rename_exact(binding.id, name)
1768
+ return RenamedBinding(binding=binding, name=normalized)
1769
+
1770
+ async def archive_exact_binding(
1771
+ self,
1772
+ *,
1773
+ target: ExactBindingTarget,
1774
+ ) -> ThreadBinding:
1775
+ async with self._scope_coordinator.hold(target.scope_key):
1776
+ binding, previous_id = self._require_exact_lifecycle_target(target)
1777
+ archived = await self._runtime.archive_exact(binding.id)
1778
+ async with self._scope_coordinator.hold(target.scope_key):
1779
+ current_id = self._active_id(target.scope_key)
1780
+ if previous_id == binding.id and current_id is None:
1781
+ await self._runtime.binding_pointer_changed(binding.id, None)
1782
+ return archived
1783
+
1784
+ async def restore_exact_binding(
1785
+ self,
1786
+ *,
1787
+ target: ExactBindingTarget,
1788
+ ) -> ThreadBinding:
1789
+ async with self._scope_coordinator.hold(target.scope_key):
1790
+ binding, _ = self._require_exact(target)
1791
+ return await self._runtime.restore_exact(binding.id)
1792
+
1793
+ async def restore_exact_binding_as_current(
1794
+ self,
1795
+ *,
1796
+ target: ExactBindingTarget,
1797
+ ) -> ThreadBinding:
1798
+ async with self._scope_coordinator.hold(target.scope_key):
1799
+ binding, previous_id = self._require_exact(target)
1800
+ restored = await self._runtime.restore_as_current_exact(binding.id)
1801
+ await self._runtime.binding_pointer_changed(previous_id, restored.id)
1802
+ return restored
1803
+
1804
+ async def delete_exact_lazy_binding(
1805
+ self,
1806
+ *,
1807
+ target: ExactBindingTarget,
1808
+ ) -> ThreadBinding:
1809
+ async with self._scope_coordinator.hold(target.scope_key):
1810
+ binding, previous_id = self._require_exact(target)
1811
+ deleted = await self._runtime.delete_lazy_exact(binding.id)
1812
+ current_id = self._active_id(target.scope_key)
1813
+ await self._runtime.binding_pointer_changed(previous_id, current_id)
1814
+ return deleted
1815
+
1816
+ async def delete_exact_binding(
1817
+ self,
1818
+ *,
1819
+ target: ExactBindingTarget,
1820
+ expected_native_thread_id: str | None,
1821
+ ) -> ThreadBinding:
1822
+ async with self._scope_coordinator.hold(target.scope_key):
1823
+ binding, previous_id = self._require_exact_lifecycle_target(target)
1824
+ if binding.native_thread_id != expected_native_thread_id:
1825
+ raise ThreadDeleteTargetChanged(
1826
+ "会话的原生 Thread 已变化,本删除确认未执行。"
1827
+ )
1828
+ deleted = await self._runtime.delete_exact(
1829
+ binding.id,
1830
+ expected_native_thread_id=expected_native_thread_id,
1831
+ )
1832
+ async with self._scope_coordinator.hold(target.scope_key):
1833
+ current_id = self._active_id(target.scope_key)
1834
+ if previous_id == binding.id and current_id is None:
1835
+ await self._runtime.binding_pointer_changed(binding.id, None)
1836
+ return deleted
1837
+
1838
+ async def delete_archived_exact_binding(
1839
+ self,
1840
+ *,
1841
+ target: ExactBindingTarget,
1842
+ expected_native_thread_id: str,
1843
+ ) -> ThreadBinding:
1844
+ async with self._scope_coordinator.hold(target.scope_key):
1845
+ binding, previous_id = self._require_exact_lifecycle_target(target)
1846
+ if binding.native_thread_id != expected_native_thread_id:
1847
+ raise ThreadDeleteTargetChanged(
1848
+ "归档会话的原生 Thread 已变化,本删除确认未执行。"
1849
+ )
1850
+ deleted = await self._runtime.delete_archived_exact(
1851
+ binding.id,
1852
+ expected_native_thread_id=expected_native_thread_id,
1853
+ )
1854
+ async with self._scope_coordinator.hold(target.scope_key):
1855
+ current_id = self._active_id(target.scope_key)
1856
+ if previous_id == binding.id and current_id is None:
1857
+ await self._runtime.binding_pointer_changed(binding.id, None)
1858
+ return deleted
1859
+
1860
+ async def release_exact_binding(
1861
+ self,
1862
+ *,
1863
+ target: ExactBindingTarget,
1864
+ runtime_precondition: RuntimePrecondition | None = None,
1865
+ ) -> ReleasedBinding:
1866
+ async with self._scope_coordinator.hold(target.scope_key):
1867
+ binding, _ = self._require_exact(target)
1868
+ self._require_runtime_precondition(binding.id, runtime_precondition)
1869
+ disposition = await self._runtime.release_exact(binding.id)
1870
+ return ReleasedBinding(binding, disposition)
1871
+
1872
+ async def stop_exact_binding(
1873
+ self,
1874
+ *,
1875
+ target: ExactBindingTarget,
1876
+ acknowledge: StopAcknowledger | None = None,
1877
+ runtime_precondition: RuntimePrecondition | None = None,
1878
+ ) -> StoppedBinding:
1879
+ async with self._scope_coordinator.hold(target.scope_key):
1880
+ binding, _ = self._require_exact(target)
1881
+ stop_kwargs: dict[str, object] = {"acknowledge": acknowledge}
1882
+ if runtime_precondition is not None:
1883
+ stop_kwargs.update(
1884
+ expected_activity_revision=(
1885
+ runtime_precondition.activity_revision
1886
+ ),
1887
+ expected_turn_id=runtime_precondition.physical_turn_id,
1888
+ )
1889
+ disposition = await self._runtime.stop_exact(
1890
+ binding.id,
1891
+ **stop_kwargs,
1892
+ )
1893
+ return StoppedBinding(binding, disposition)
1894
+
1895
+ async def recheck_exact_turn(
1896
+ self,
1897
+ *,
1898
+ target: ExactBindingTarget,
1899
+ runtime_precondition: RuntimePrecondition,
1900
+ ) -> ActiveTurnSnapshot:
1901
+ async with self._scope_coordinator.hold(target.scope_key):
1902
+ binding, _ = self._require_exact(target)
1903
+ turn_id = runtime_precondition.physical_turn_id
1904
+ if turn_id is None:
1905
+ raise RuntimeStateChanged("recheck requires an exact physical Turn")
1906
+ return await self._runtime.recheck_turn_exact(
1907
+ binding.id,
1908
+ expected_activity_revision=runtime_precondition.activity_revision,
1909
+ expected_turn_id=turn_id,
1910
+ )
1911
+
1912
+ async def close_side(
1913
+ self,
1914
+ *,
1915
+ target: CurrentSideTarget,
1916
+ ) -> ClosedSide:
1917
+ record = self._bindings.get_side_topic(target.side_id)
1918
+ self._require_side_identity(record, target)
1919
+ if record.state.terminal and self._runtime.side_snapshot_exact(record.id) is None:
1920
+ return ClosedSide(record=record, outcome=None)
1921
+ if record.state.terminal:
1922
+ state = record.state
1923
+ elif record.state is SideTopicState.CREATING:
1924
+ state = SideTopicState.FAILED
1925
+ else:
1926
+ state = SideTopicState.CLOSED
1927
+ try:
1928
+ outcome = await self._runtime.close_side_exact(record.id, state=state)
1929
+ except SideSessionNotFound:
1930
+ current = self._bindings.get_side_topic(record.id)
1931
+ self._require_side_identity(current, target)
1932
+ if current.state is SideTopicState.OPEN:
1933
+ terminal = SideTopicState.EXPIRED
1934
+ elif current.state is SideTopicState.CREATING:
1935
+ terminal = SideTopicState.FAILED
1936
+ else:
1937
+ return ClosedSide(
1938
+ record=current,
1939
+ outcome=None,
1940
+ missing_runtime_session=True,
1941
+ )
1942
+ try:
1943
+ current = self._bindings.transition_side_topic(current.id, terminal)
1944
+ except SideTopicConflict:
1945
+ current = self._bindings.get_side_topic(current.id)
1946
+ return ClosedSide(
1947
+ record=current,
1948
+ outcome=None,
1949
+ missing_runtime_session=True,
1950
+ )
1951
+ return ClosedSide(
1952
+ record=self._bindings.get_side_topic(record.id),
1953
+ outcome=outcome,
1954
+ )
1955
+
1956
+ @staticmethod
1957
+ def _enrichment_deadline(deadline: float, seconds: float) -> float:
1958
+ # Keep time for local rows, labels and Runtime projections if native I/O stalls.
1959
+ now = asyncio.get_running_loop().time()
1960
+ return now + min(seconds, max(0.0, (deadline - now) / 2))
1961
+
1962
+ async def _indexed_native_views(
1963
+ self,
1964
+ *,
1965
+ deadline: float,
1966
+ thread_ids: tuple[str, ...] | None = None,
1967
+ ) -> dict[str, NativeThreadView] | None:
1968
+ if thread_ids == ():
1969
+ return {}
1970
+ native_deadline = self._enrichment_deadline(deadline, _NATIVE_INDEX_READ_SECONDS)
1971
+ try:
1972
+ catalogs = []
1973
+ async with asyncio.timeout_at(native_deadline):
1974
+ for archived in (False, True):
1975
+ if thread_ids is None:
1976
+ catalog = await self._runtime.thread_catalog_exact(
1977
+ archived=archived, deadline=native_deadline,
1978
+ use_state_db_only=True,
1979
+ )
1980
+ catalogs.append(catalog.by_id())
1981
+ else:
1982
+ catalogs.append(await self._runtime.thread_metadata_exact(
1983
+ thread_ids, archived=archived, deadline=native_deadline,
1984
+ use_state_db_only=True,
1985
+ ))
1986
+ active, archived = catalogs
1987
+ if len(active.keys() | archived.keys()) > 100_000:
1988
+ raise ValueError("management index exceeds the catalog limit")
1989
+ except Exception as error:
1990
+ # Native catalog enrichment cannot hide Channel-owned rows. Never
1991
+ # fall back to rollout scans or infer Missing from an unavailable list.
1992
+ logger.warning("management index unavailable error_type=%s", type(error).__name__)
1993
+ return None
1994
+ return {
1995
+ thread_id: (
1996
+ NativeThreadView(None, None)
1997
+ if thread_id in active and thread_id in archived
1998
+ else classify_native_thread_view(thread_id, active=active, archived=archived)
1999
+ )
2000
+ for thread_id in active.keys() | archived.keys()
2001
+ }
2002
+
2003
+ async def _session_page(
2004
+ self,
2005
+ records: Sequence[BindingInventoryRecord],
2006
+ *,
2007
+ native: dict[str, NativeThreadView] | None,
2008
+ next_cursor: BindingCursor | None,
2009
+ deadline: float,
2010
+ ) -> SessionInventoryPage:
2011
+ views = await self._session_native_views(
2012
+ tuple(record.binding for record in records), native=native, deadline=deadline,
2013
+ )
2014
+ labels = await self.resolve_chat_labels(
2015
+ (record.scope.chat_id for record in records), deadline=deadline,
2016
+ )
2017
+ return SessionInventoryPage(
2018
+ items=tuple(
2019
+ SessionInventoryItem(
2020
+ record=record,
2021
+ native=views.get(record.binding.native_thread_id),
2022
+ chat=labels[record.scope.chat_id],
2023
+ )
2024
+ for record in records
2025
+ ),
2026
+ next_cursor=next_cursor,
2027
+ catalog_available=native is not None,
2028
+ )
2029
+
2030
+ async def _session_native_views(
2031
+ self,
2032
+ bindings: Sequence[ThreadBinding],
2033
+ *,
2034
+ native: dict[str, NativeThreadView] | None,
2035
+ deadline: float,
2036
+ ) -> dict[str, NativeThreadView]:
2037
+ views = {
2038
+ binding.native_thread_id: (native or {}).get(
2039
+ binding.native_thread_id, NativeThreadView(None, None),
2040
+ )
2041
+ for binding in bindings if binding.native_thread_id is not None
2042
+ }
2043
+ # Only visible, unclassified rows need a summary. Reading one exact ID
2044
+ # neither proves archive membership nor turns a read failure into Missing.
2045
+ # Use the existing request deadline for the whole page, including queuing.
2046
+
2047
+ async def hydrate(thread_id: str) -> None:
2048
+ try:
2049
+ async with asyncio.timeout_at(deadline):
2050
+ await self._summary_read_limiter.acquire()
2051
+ if self._summary_reads_closed or asyncio.get_running_loop().time() >= deadline:
2052
+ self._summary_read_limiter.release()
2053
+ return
2054
+ task = asyncio.create_task(self._runtime.thread_summary_exact(thread_id))
2055
+ self._summary_reads.add(task)
2056
+ task.add_done_callback(self._summary_read_finished)
2057
+ metadata = await asyncio.shield(task)
2058
+ views[thread_id] = NativeThreadView(None, metadata)
2059
+ except Exception:
2060
+ pass # The exact row remains visible with unknown state and its ID.
2061
+
2062
+ unresolved = tuple(
2063
+ thread_id for thread_id, view in views.items()
2064
+ if view.state is None and view.metadata is None
2065
+ )
2066
+ await asyncio.gather(*(hydrate(thread_id) for thread_id in unresolved))
2067
+ return views
2068
+
2069
+ def _summary_read_finished(self, task: asyncio.Task[NativeThreadMetadata]) -> None:
2070
+ self._summary_reads.discard(task)
2071
+ self._summary_read_limiter.release()
2072
+ if not task.cancelled():
2073
+ task.exception() # Consume failures even after the page wait expired.
2074
+
2075
+ async def resolve_chat_labels(
2076
+ self,
2077
+ chat_ids: Iterable[str],
2078
+ *,
2079
+ deadline: float,
2080
+ ) -> dict[str, ChatLabel]:
2081
+ """Enrich a bounded management result page using the shared label cache."""
2082
+ unique = tuple(dict.fromkeys(chat_ids))
2083
+ resolver = self._chat_labels
2084
+ if resolver is None:
2085
+ return {
2086
+ chat_id: ChatLabelResolver.fallback(chat_id)
2087
+ for chat_id in unique
2088
+ }
2089
+ return await resolver.resolve_many(unique, deadline=deadline)
2090
+
2091
+ @staticmethod
2092
+ def _query_seconds(deadline: float) -> float:
2093
+ remaining = deadline - asyncio.get_running_loop().time()
2094
+ if remaining <= 0:
2095
+ raise TimeoutError("management query deadline elapsed")
2096
+ return min(0.5, remaining)
2097
+
2098
+ @staticmethod
2099
+ def _validated_snapshot_ids(
2100
+ values: Sequence[str],
2101
+ label: str,
2102
+ ) -> tuple[str, ...]:
2103
+ result = tuple(values)
2104
+ if len(set(result)) != len(result):
2105
+ raise ValueError(f"duplicate {label} snapshot ID")
2106
+ if any(
2107
+ not isinstance(value, str) or not value or value.strip() != value
2108
+ for value in result
2109
+ ):
2110
+ raise ValueError(f"{label} snapshot IDs must be non-empty exact strings")
2111
+ return result
2112
+
2113
+ @staticmethod
2114
+ def _raise_project_create_error(alias: str, error: Exception) -> NoReturn:
2115
+ if isinstance(error, StoredProjectNotFound):
2116
+ raise UnknownProject(alias) from error
2117
+ if isinstance(error, StoredProjectDisabled):
2118
+ raise ProjectDisabled(f"Project {alias} 已停用,不能创建新会话。") from error
2119
+ if isinstance(error, StoredProjectRevisionConflict):
2120
+ raise StaleProject(
2121
+ f"Project {alias} 已被其他操作修改,请刷新后重试。"
2122
+ ) from error
2123
+ raise AssertionError("unexpected Project create error") from error
2124
+
2125
+ def _require_current(self, target: CurrentBindingTarget) -> ThreadBinding:
2126
+ binding = self._bindings.active_binding(target.scope_key)
2127
+ if binding is None:
2128
+ raise NoCurrentBinding(target.scope_key)
2129
+ if binding.id != target.binding_id:
2130
+ raise CurrentBindingChanged(target.binding_id)
2131
+ return binding
2132
+
2133
+ def _require_runtime_precondition(
2134
+ self,
2135
+ binding_id: str,
2136
+ expected: RuntimePrecondition | None,
2137
+ ) -> None:
2138
+ if expected is None:
2139
+ return
2140
+ snapshot = self._runtime.runtime_snapshot_exact(binding_id)
2141
+ physical_turn_id = snapshot.turn.turn_id if snapshot.turn is not None else None
2142
+ if (
2143
+ snapshot.activity_revision != expected.activity_revision
2144
+ or physical_turn_id != expected.physical_turn_id
2145
+ ):
2146
+ raise RuntimeStateChanged(binding_id)
2147
+
2148
+ def _require_exact(
2149
+ self,
2150
+ target: ExactBindingTarget,
2151
+ ) -> tuple[ThreadBinding, str | None]:
2152
+ current_id = self._active_id(target.scope_key)
2153
+ if current_id != target.expected_active_binding_id:
2154
+ raise ActivePointerChanged(target.scope_key)
2155
+ binding = self._bindings.get(target.binding_id)
2156
+ if binding.scope_key != target.scope_key:
2157
+ raise BindingScopeMismatch(target.binding_id)
2158
+ return binding, current_id
2159
+
2160
+ def _require_exact_lifecycle_target(
2161
+ self,
2162
+ target: ExactBindingTarget,
2163
+ ) -> tuple[ThreadBinding, str | None]:
2164
+ """Resolve an exact Thread without coupling it to the Scope pointer."""
2165
+
2166
+ current_id = self._active_id(target.scope_key)
2167
+ binding = self._bindings.get(target.binding_id)
2168
+ if binding.scope_key != target.scope_key:
2169
+ raise BindingScopeMismatch(target.binding_id)
2170
+ return binding, current_id
2171
+
2172
+ def _active_id(self, scope_key: str) -> str | None:
2173
+ active = self._bindings.active_binding(scope_key)
2174
+ return active.id if active is not None else None
2175
+
2176
+ @staticmethod
2177
+ def _require_side_identity(
2178
+ record: SideTopicRecord,
2179
+ target: CurrentSideTarget,
2180
+ ) -> None:
2181
+ if (
2182
+ record.app_id != target.app_id
2183
+ or record.chat_id != target.chat_id
2184
+ or record.topic_id != target.topic_id
2185
+ or record.root_message_id != target.root_message_id
2186
+ ):
2187
+ raise SideIdentityMismatch(target.side_id)