netizen-cli 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|