netizen-cli 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
netizen_cli/admin/web.py
ADDED
|
@@ -0,0 +1,2717 @@
|
|
|
1
|
+
"""Authenticated in-process Admin Web application and lifecycle runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import errno
|
|
7
|
+
import hashlib
|
|
8
|
+
import html
|
|
9
|
+
import importlib.resources
|
|
10
|
+
import ipaddress
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from http.cookies import SimpleCookie
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, TypeVar
|
|
20
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
21
|
+
|
|
22
|
+
from .auth import (
|
|
23
|
+
ActionCsrfRejected,
|
|
24
|
+
AdmissionClosed,
|
|
25
|
+
AdminAuth,
|
|
26
|
+
AdminAuthError,
|
|
27
|
+
AuthCapacityExceeded,
|
|
28
|
+
ConsumedActionGrant,
|
|
29
|
+
ExpectedValue,
|
|
30
|
+
LoginRejected,
|
|
31
|
+
MalformedActionGrant,
|
|
32
|
+
SessionRejected,
|
|
33
|
+
StaleActionGrant,
|
|
34
|
+
)
|
|
35
|
+
from .errors import AdminWebError
|
|
36
|
+
from .presentation import (
|
|
37
|
+
_binding_result,
|
|
38
|
+
_chat_open_url,
|
|
39
|
+
_jsonable,
|
|
40
|
+
_project_result,
|
|
41
|
+
_release_check_message,
|
|
42
|
+
_release_disposition_message,
|
|
43
|
+
_runtime_binding_json,
|
|
44
|
+
_runtime_side_json,
|
|
45
|
+
_schedule_plan_json,
|
|
46
|
+
_settings_json,
|
|
47
|
+
_stop_disposition_message,
|
|
48
|
+
_topic_open_url,
|
|
49
|
+
)
|
|
50
|
+
from .queries import (
|
|
51
|
+
_created_range_query,
|
|
52
|
+
_current_query,
|
|
53
|
+
_decode_binding_cursor,
|
|
54
|
+
_decode_project_cursor,
|
|
55
|
+
_decode_side_cursor,
|
|
56
|
+
_encode_binding_cursor,
|
|
57
|
+
_encode_project_cursor,
|
|
58
|
+
_encode_side_cursor,
|
|
59
|
+
_fingerprint,
|
|
60
|
+
_id_query,
|
|
61
|
+
_optional_bool_query,
|
|
62
|
+
_optional_one,
|
|
63
|
+
_optional_text_query,
|
|
64
|
+
_page_size,
|
|
65
|
+
_require_query_keys,
|
|
66
|
+
_scope_kinds_query,
|
|
67
|
+
_session_inventory_states,
|
|
68
|
+
_session_page_size,
|
|
69
|
+
_text_set_query,
|
|
70
|
+
)
|
|
71
|
+
from .transport import AdminHttpState, AdminHttpTransport, Request, Response
|
|
72
|
+
from .port_config import ConfigFileSnapshot, persist_admin_port
|
|
73
|
+
from ..instance import instance_digest
|
|
74
|
+
from ..settings import validate_access_host
|
|
75
|
+
from ..bindings import (
|
|
76
|
+
AmbiguousBinding,
|
|
77
|
+
BindingNotFound,
|
|
78
|
+
BindingQuery,
|
|
79
|
+
BindingQueryBusy,
|
|
80
|
+
BindingQueryClosed,
|
|
81
|
+
BindingQueryTimeout,
|
|
82
|
+
BindingSettingsRevisionConflict,
|
|
83
|
+
BindingTurnSettings,
|
|
84
|
+
ProjectConflict as StoredProjectConflict,
|
|
85
|
+
ScopeNotFound,
|
|
86
|
+
SideTopicNotFound,
|
|
87
|
+
SideTopicQuery,
|
|
88
|
+
SideTopicState,
|
|
89
|
+
ThreadBinding,
|
|
90
|
+
)
|
|
91
|
+
from ..management.blocking_io import (
|
|
92
|
+
BlockingIODrainTimeout,
|
|
93
|
+
BlockingIOExecutorClosed,
|
|
94
|
+
BlockingIOExecutorSaturated,
|
|
95
|
+
BlockingIOResultUnknown,
|
|
96
|
+
BlockingIOShutdownTimeout,
|
|
97
|
+
)
|
|
98
|
+
from ..management.updates import UpdateError
|
|
99
|
+
from ..runtime.contracts import (
|
|
100
|
+
NativeThreadCatalogState,
|
|
101
|
+
RuntimeClosed,
|
|
102
|
+
SideCloseFailed,
|
|
103
|
+
SideSessionConflict,
|
|
104
|
+
SideSessionNotFound,
|
|
105
|
+
ThreadArchived,
|
|
106
|
+
ThreadBackgroundTerminalsActive,
|
|
107
|
+
ThreadCatalogError,
|
|
108
|
+
ThreadCatalogDeadlineExceeded,
|
|
109
|
+
ThreadCompacting,
|
|
110
|
+
ThreadDeleteUnavailable,
|
|
111
|
+
ThreadGoalActive,
|
|
112
|
+
ThreadLifecycleError,
|
|
113
|
+
ThreadLifecycleStateUnknown,
|
|
114
|
+
ThreadNotArchived,
|
|
115
|
+
ThreadNotMaterialized,
|
|
116
|
+
ThreadOccupied,
|
|
117
|
+
ThreadReleaseError,
|
|
118
|
+
ThreadReleaseStateUnknown,
|
|
119
|
+
ThreadRunningConfiguration,
|
|
120
|
+
)
|
|
121
|
+
from ..domain import MentionContextMode, ScopeKind
|
|
122
|
+
from ..management import (
|
|
123
|
+
ActivePointerChanged,
|
|
124
|
+
BindingStatusProjection,
|
|
125
|
+
ChatLabel,
|
|
126
|
+
BindingScopeMismatch,
|
|
127
|
+
CurrentSideTarget,
|
|
128
|
+
ExactBindingTarget,
|
|
129
|
+
InstanceManagementService,
|
|
130
|
+
NativeCatalogInconsistent,
|
|
131
|
+
NativeThreadMissing,
|
|
132
|
+
ProjectDeletionResult,
|
|
133
|
+
RuntimePrecondition,
|
|
134
|
+
RuntimeStateChanged,
|
|
135
|
+
SessionInventoryItem,
|
|
136
|
+
SessionQuery,
|
|
137
|
+
SideIdentityMismatch,
|
|
138
|
+
)
|
|
139
|
+
from ..projects import (
|
|
140
|
+
ProjectAlreadyExists,
|
|
141
|
+
ProjectDisabled,
|
|
142
|
+
ProjectError,
|
|
143
|
+
StaleProject,
|
|
144
|
+
UnknownProject,
|
|
145
|
+
)
|
|
146
|
+
from ..schedules.models import AmbiguousLocalTime, ScheduleError, resolve_once_local
|
|
147
|
+
from ..defaults import DefaultConfigurationError
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
logger = logging.getLogger(__name__)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_SESSION_COOKIE = "netizen_admin_session"
|
|
154
|
+
_PREAUTH_COOKIE = "netizen_admin_preauth"
|
|
155
|
+
_JSON_TYPE = "application/json"
|
|
156
|
+
_FORM_TYPE = "application/x-www-form-urlencoded"
|
|
157
|
+
_QUERY_DEADLINE_SECONDS = 5.0
|
|
158
|
+
_SESSION_QUERY_DEADLINE_SECONDS = 10.0
|
|
159
|
+
_MUTATION_DEADLINE_SECONDS = 15.0
|
|
160
|
+
_MAX_QUERY_FIELDS = 256
|
|
161
|
+
_MAX_JSON_FIELDS = 20
|
|
162
|
+
_RUNTIME_SNAPSHOT_BATCH_SIZE = 50
|
|
163
|
+
_AUTO_PORTS = range(8787, 8887)
|
|
164
|
+
|
|
165
|
+
_SECURITY_HEADERS = (
|
|
166
|
+
(b"Cache-Control", b"no-store"),
|
|
167
|
+
(
|
|
168
|
+
b"Content-Security-Policy",
|
|
169
|
+
b"default-src 'self'; script-src 'self'; style-src 'self'; "
|
|
170
|
+
b"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; "
|
|
171
|
+
b"form-action 'self'",
|
|
172
|
+
),
|
|
173
|
+
(b"X-Content-Type-Options", b"nosniff"),
|
|
174
|
+
# Basic HTML form submissions serialize Origin as "null" under
|
|
175
|
+
# no-referrer. same-origin keeps cross-origin referrers suppressed while
|
|
176
|
+
# preserving the exact Origin required by the login CSRF boundary.
|
|
177
|
+
(b"Referrer-Policy", b"same-origin"),
|
|
178
|
+
(b"X-Frame-Options", b"DENY"),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class AdminMutationDrainTimeout(TimeoutError):
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class AdminActionTarget:
|
|
188
|
+
resource: str
|
|
189
|
+
target_id: str
|
|
190
|
+
scope_key: str | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class AdminActionPreconditions:
|
|
195
|
+
active_binding_id: ExpectedValue[str]
|
|
196
|
+
project_revision: ExpectedValue[int]
|
|
197
|
+
settings_revision: ExpectedValue[int]
|
|
198
|
+
native_thread_id: ExpectedValue[str]
|
|
199
|
+
activity_revision: ExpectedValue[int]
|
|
200
|
+
physical_turn_id: ExpectedValue[str]
|
|
201
|
+
side_app_id: ExpectedValue[str]
|
|
202
|
+
side_chat_id: ExpectedValue[str]
|
|
203
|
+
side_topic_id: ExpectedValue[str]
|
|
204
|
+
side_root_message_id: ExpectedValue[str]
|
|
205
|
+
inventory_fingerprint: ExpectedValue[str]
|
|
206
|
+
schedule_revision: ExpectedValue[int]
|
|
207
|
+
default_revision: ExpectedValue[int]
|
|
208
|
+
default_order_revision: ExpectedValue[int]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class _RequestContext:
|
|
213
|
+
request: Request
|
|
214
|
+
method: str
|
|
215
|
+
path: str
|
|
216
|
+
query: Mapping[str, list[str]]
|
|
217
|
+
authority: str
|
|
218
|
+
source_ip: str
|
|
219
|
+
session_token: str | None
|
|
220
|
+
session_log_handle: str | None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
T = TypeVar("T")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class AdminWebApplication:
|
|
227
|
+
"""HTTP application with no direct access to CodexRuntime or SQLite."""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
auth: AdminAuth,
|
|
233
|
+
management: InstanceManagementService,
|
|
234
|
+
instance_root: Path | None = None,
|
|
235
|
+
) -> None:
|
|
236
|
+
self._auth = auth
|
|
237
|
+
self._management = management
|
|
238
|
+
self._instance_root = instance_root
|
|
239
|
+
suffix = f"_{instance_digest(instance_root)}" if instance_root is not None else ""
|
|
240
|
+
self._session_cookie = _SESSION_COOKIE + suffix
|
|
241
|
+
self._preauth_cookie = _PREAUTH_COOKIE + suffix
|
|
242
|
+
self._ready = False
|
|
243
|
+
self._authorities: frozenset[str] = frozenset()
|
|
244
|
+
self._origins: frozenset[str] = frozenset()
|
|
245
|
+
self._mutation_tasks: set[asyncio.Task[object]] = set()
|
|
246
|
+
self._assets = _load_assets()
|
|
247
|
+
self._assets["index.html"] = self._assets["index.html"].replace(
|
|
248
|
+
b"__NETIZEN_INSTANCE_ROOT__", html.escape(_root_label(instance_root)).encode("utf-8")
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def ready(self) -> bool:
|
|
253
|
+
return self._ready
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def mutation_task_count(self) -> int:
|
|
257
|
+
return len(self._mutation_tasks)
|
|
258
|
+
|
|
259
|
+
def configure_authorities(self, authorities: Sequence[str]) -> None:
|
|
260
|
+
if self._ready:
|
|
261
|
+
raise RuntimeError("cannot change Admin authorities after admission")
|
|
262
|
+
normalized = frozenset(authority.lower() for authority in authorities)
|
|
263
|
+
if not normalized or any(not authority for authority in normalized):
|
|
264
|
+
raise ValueError("Admin authority allowlist must not be empty")
|
|
265
|
+
self._authorities = normalized
|
|
266
|
+
self._origins = frozenset(f"http://{authority}" for authority in normalized)
|
|
267
|
+
|
|
268
|
+
def open_admission(self) -> None:
|
|
269
|
+
if not self._authorities:
|
|
270
|
+
raise RuntimeError("Admin authorities are not configured")
|
|
271
|
+
if not self._auth.admission_open:
|
|
272
|
+
raise RuntimeError("Admin authentication admission is closed")
|
|
273
|
+
self._ready = True
|
|
274
|
+
|
|
275
|
+
def close_admission(self) -> None:
|
|
276
|
+
self._ready = False
|
|
277
|
+
|
|
278
|
+
def close_auth(self) -> None:
|
|
279
|
+
self._ready = False
|
|
280
|
+
self._auth.close()
|
|
281
|
+
|
|
282
|
+
async def drain(self, deadline: float) -> None:
|
|
283
|
+
while self._mutation_tasks:
|
|
284
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
285
|
+
if remaining <= 0:
|
|
286
|
+
raise AdminMutationDrainTimeout(
|
|
287
|
+
"Admin mutations did not drain before shutdown deadline"
|
|
288
|
+
)
|
|
289
|
+
done, _pending = await asyncio.wait(
|
|
290
|
+
tuple(self._mutation_tasks),
|
|
291
|
+
timeout=remaining,
|
|
292
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
293
|
+
)
|
|
294
|
+
if not done:
|
|
295
|
+
raise AdminMutationDrainTimeout(
|
|
296
|
+
"Admin mutations did not drain before shutdown deadline"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def handle(self, request: Request) -> Response:
|
|
300
|
+
try:
|
|
301
|
+
context = self._context(request)
|
|
302
|
+
response = await self._dispatch(context)
|
|
303
|
+
except AdminWebError as error:
|
|
304
|
+
response = _json_error(
|
|
305
|
+
request.request_id,
|
|
306
|
+
error.status,
|
|
307
|
+
error.code,
|
|
308
|
+
error.message,
|
|
309
|
+
)
|
|
310
|
+
except BaseException as error:
|
|
311
|
+
if isinstance(error, asyncio.CancelledError):
|
|
312
|
+
raise
|
|
313
|
+
mapped = _map_error(error)
|
|
314
|
+
if mapped is None:
|
|
315
|
+
# Exception text can originate in filesystem/native adapters
|
|
316
|
+
# and may contain cwd, title, preview, or request-derived data.
|
|
317
|
+
# Keep the server-side signal useful without serializing it.
|
|
318
|
+
logger.error(
|
|
319
|
+
"admin request failed request_id=%s error_type=%s",
|
|
320
|
+
request.request_id,
|
|
321
|
+
type(error).__name__,
|
|
322
|
+
)
|
|
323
|
+
mapped = AdminWebError(500, "internal_error", "服务内部错误。")
|
|
324
|
+
response = _json_error(
|
|
325
|
+
request.request_id,
|
|
326
|
+
mapped.status,
|
|
327
|
+
mapped.code,
|
|
328
|
+
mapped.message,
|
|
329
|
+
)
|
|
330
|
+
return _secure(response)
|
|
331
|
+
|
|
332
|
+
def _context(self, request: Request) -> _RequestContext:
|
|
333
|
+
authority = _single_header(request, b"host").decode("ascii").lower()
|
|
334
|
+
if authority not in self._authorities:
|
|
335
|
+
raise AdminWebError(400, "invalid_host", "请求 Host 不受信任。")
|
|
336
|
+
try:
|
|
337
|
+
target = request.target.decode("ascii")
|
|
338
|
+
except UnicodeDecodeError:
|
|
339
|
+
raise AdminWebError(400, "invalid_target", "请求 URL 无效。") from None
|
|
340
|
+
split = urlsplit(target)
|
|
341
|
+
if not split.path.startswith("/") or split.scheme or split.netloc or split.fragment:
|
|
342
|
+
raise AdminWebError(400, "invalid_target", "请求 URL 无效。")
|
|
343
|
+
try:
|
|
344
|
+
query = parse_qs(
|
|
345
|
+
split.query,
|
|
346
|
+
keep_blank_values=True,
|
|
347
|
+
strict_parsing=True,
|
|
348
|
+
max_num_fields=_MAX_QUERY_FIELDS,
|
|
349
|
+
)
|
|
350
|
+
except ValueError:
|
|
351
|
+
raise AdminWebError(400, "invalid_query", "查询参数无效。") from None
|
|
352
|
+
cookies = _cookies(request)
|
|
353
|
+
peer = request.peer
|
|
354
|
+
if not isinstance(peer, tuple) or not peer or not isinstance(peer[0], str):
|
|
355
|
+
raise AdminWebError(400, "invalid_source", "请求来源无效。")
|
|
356
|
+
return _RequestContext(
|
|
357
|
+
request=request,
|
|
358
|
+
method=request.method.decode("ascii"),
|
|
359
|
+
path=split.path,
|
|
360
|
+
query=query,
|
|
361
|
+
authority=authority,
|
|
362
|
+
source_ip=peer[0],
|
|
363
|
+
session_token=cookies.get(self._session_cookie),
|
|
364
|
+
session_log_handle=None,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def _dispatch(self, context: _RequestContext) -> Response:
|
|
368
|
+
if context.path == "/health/ready" and context.method == "GET":
|
|
369
|
+
return Response(204 if self._ready else 503)
|
|
370
|
+
if not self._ready:
|
|
371
|
+
raise AdminWebError(503, "not_ready", "管理服务尚未就绪。")
|
|
372
|
+
if context.path == "/static/admin.css" and context.method == "GET":
|
|
373
|
+
# The login page reuses the same inert stylesheet. Keep HTML,
|
|
374
|
+
# JavaScript, API routes, and all instance state authenticated.
|
|
375
|
+
return Response(
|
|
376
|
+
200,
|
|
377
|
+
((b"Content-Type", b"text/css; charset=utf-8"),),
|
|
378
|
+
self._assets["admin.css"],
|
|
379
|
+
)
|
|
380
|
+
if context.path == "/login" and context.method == "GET":
|
|
381
|
+
return self._login_page(context)
|
|
382
|
+
if context.path == "/login" and context.method == "POST":
|
|
383
|
+
self._require_origin(context)
|
|
384
|
+
return self._login(context)
|
|
385
|
+
|
|
386
|
+
if context.path == "/" and context.method == "GET":
|
|
387
|
+
# The browser entry point redirects anonymous visitors to the
|
|
388
|
+
# login page. Every other route (API, static assets other than
|
|
389
|
+
# the login stylesheet, and unknown paths) still fails closed
|
|
390
|
+
# with a JSON 401 so the anonymous content surface stays minimal.
|
|
391
|
+
session = self._authenticate_or_none(context)
|
|
392
|
+
if session is None:
|
|
393
|
+
return Response(303, ((b"Location", b"/login"),))
|
|
394
|
+
else:
|
|
395
|
+
session = self._authenticate(context)
|
|
396
|
+
context = _RequestContext(
|
|
397
|
+
request=context.request,
|
|
398
|
+
method=context.method,
|
|
399
|
+
path=context.path,
|
|
400
|
+
query=context.query,
|
|
401
|
+
authority=context.authority,
|
|
402
|
+
source_ip=context.source_ip,
|
|
403
|
+
session_token=context.session_token,
|
|
404
|
+
session_log_handle=session.log_handle,
|
|
405
|
+
)
|
|
406
|
+
if context.method == "POST":
|
|
407
|
+
self._require_origin(context)
|
|
408
|
+
return await self._dispatch_authenticated(context)
|
|
409
|
+
|
|
410
|
+
def _authenticate(self, context: _RequestContext):
|
|
411
|
+
try:
|
|
412
|
+
return self._auth.authenticate(context.session_token)
|
|
413
|
+
except (SessionRejected, AdmissionClosed):
|
|
414
|
+
raise AdminWebError(401, "not_authenticated", "请先登录。") from None
|
|
415
|
+
|
|
416
|
+
def _authenticate_or_none(self, context: _RequestContext):
|
|
417
|
+
try:
|
|
418
|
+
return self._auth.authenticate(context.session_token)
|
|
419
|
+
except (SessionRejected, AdmissionClosed):
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
def _require_origin(self, context: _RequestContext) -> None:
|
|
423
|
+
try:
|
|
424
|
+
origin = _single_header(context.request, b"origin").decode("ascii")
|
|
425
|
+
except UnicodeDecodeError:
|
|
426
|
+
raise AdminWebError(403, "invalid_origin", "请求 Origin 不受信任。") from None
|
|
427
|
+
if origin not in self._origins:
|
|
428
|
+
raise AdminWebError(403, "invalid_origin", "请求 Origin 不受信任。")
|
|
429
|
+
|
|
430
|
+
def _login_page(self, context: _RequestContext) -> Response:
|
|
431
|
+
try:
|
|
432
|
+
challenge = self._auth.issue_preauth(context.source_ip)
|
|
433
|
+
except LoginRejected:
|
|
434
|
+
raise AdminWebError(401, "login_rejected", "登录暂不可用,请稍后重试。") from None
|
|
435
|
+
page = _login_html(challenge.form_nonce, self._instance_root)
|
|
436
|
+
return Response(
|
|
437
|
+
200,
|
|
438
|
+
headers=(
|
|
439
|
+
(b"Content-Type", b"text/html; charset=utf-8"),
|
|
440
|
+
(b"Set-Cookie", _cookie(self._preauth_cookie, challenge.cookie_token)),
|
|
441
|
+
),
|
|
442
|
+
body=page,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def _login(self, context: _RequestContext) -> Response:
|
|
446
|
+
form = _parse_form(context.request)
|
|
447
|
+
cookies = _cookies(context.request)
|
|
448
|
+
try:
|
|
449
|
+
issued = self._auth.login(
|
|
450
|
+
source_ip=context.source_ip,
|
|
451
|
+
cookie_token=cookies.get(self._preauth_cookie),
|
|
452
|
+
form_nonce=_one(form, "nonce"),
|
|
453
|
+
credential=_one(form, "credential"),
|
|
454
|
+
)
|
|
455
|
+
except LoginRejected:
|
|
456
|
+
raise AdminWebError(401, "login_rejected", "登录凭据无效。") from None
|
|
457
|
+
return Response(
|
|
458
|
+
303,
|
|
459
|
+
headers=(
|
|
460
|
+
(b"Location", b"/"),
|
|
461
|
+
(b"Set-Cookie", _cookie(self._session_cookie, issued.token)),
|
|
462
|
+
(b"Set-Cookie", _expired_cookie(self._preauth_cookie)),
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
async def _dispatch_authenticated(self, context: _RequestContext) -> Response:
|
|
467
|
+
route = (context.method, context.path)
|
|
468
|
+
if route == ("GET", "/"):
|
|
469
|
+
return Response(
|
|
470
|
+
200,
|
|
471
|
+
((b"Content-Type", b"text/html; charset=utf-8"),),
|
|
472
|
+
self._assets["index.html"],
|
|
473
|
+
)
|
|
474
|
+
if route == ("GET", "/static/admin.js"):
|
|
475
|
+
return Response(
|
|
476
|
+
200,
|
|
477
|
+
((b"Content-Type", b"text/javascript; charset=utf-8"),),
|
|
478
|
+
self._assets["admin.js"],
|
|
479
|
+
)
|
|
480
|
+
if route == ("POST", "/logout"):
|
|
481
|
+
self._auth.logout(context.session_token)
|
|
482
|
+
return Response(
|
|
483
|
+
204,
|
|
484
|
+
((b"Set-Cookie", _expired_cookie(self._session_cookie)),),
|
|
485
|
+
)
|
|
486
|
+
if route == ("GET", "/api/v1/projects"):
|
|
487
|
+
return await self._projects(context)
|
|
488
|
+
if route == ("GET", "/api/v1/schedules"):
|
|
489
|
+
return await self._schedules(context)
|
|
490
|
+
if route == ("GET", "/api/v1/defaults"):
|
|
491
|
+
return await self._defaults(context)
|
|
492
|
+
if route == ("GET", "/api/v1/sessions"):
|
|
493
|
+
return await self._sessions(context)
|
|
494
|
+
if route == ("GET", "/api/v1/projects/options"):
|
|
495
|
+
return await self._project_options(context)
|
|
496
|
+
if route == ("GET", "/api/v1/runtime-snapshots"):
|
|
497
|
+
return await self._runtime_snapshots(context)
|
|
498
|
+
if route == ("GET", "/api/v1/side-topics"):
|
|
499
|
+
return await self._side_topics(context)
|
|
500
|
+
if route == ("GET", "/api/v1/updates"):
|
|
501
|
+
_require_query_keys(context.query, set())
|
|
502
|
+
return self._updates_response(
|
|
503
|
+
context, await self._management.update_status()
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
mutations: dict[str, Callable[[_RequestContext], Awaitable[Response]]] = {
|
|
507
|
+
"/api/v1/defaults/save": self._default_save,
|
|
508
|
+
"/api/v1/defaults/delete": self._default_delete,
|
|
509
|
+
"/api/v1/defaults/reorder": self._default_reorder,
|
|
510
|
+
"/api/v1/schedules/create": self._schedule_create,
|
|
511
|
+
"/api/v1/schedules/update": self._schedule_update,
|
|
512
|
+
"/api/v1/schedules/delete": self._schedule_delete,
|
|
513
|
+
"/api/v1/schedules/run-now": self._schedule_run_now,
|
|
514
|
+
"/api/v1/updates/check": self._updates_check,
|
|
515
|
+
"/api/v1/updates/install": self._updates_install,
|
|
516
|
+
"/api/v1/updates/restart": self._updates_restart,
|
|
517
|
+
"/api/v1/projects/register": self._project_register,
|
|
518
|
+
"/api/v1/projects/create-directory": self._project_create_directory,
|
|
519
|
+
"/api/v1/projects/set-enabled": self._project_set_enabled,
|
|
520
|
+
"/api/v1/projects/delete-preview": self._project_delete_preview,
|
|
521
|
+
"/api/v1/projects/delete": self._project_delete,
|
|
522
|
+
"/api/v1/sessions/create-lazy": self._session_create_lazy,
|
|
523
|
+
"/api/v1/sessions/activate": self._session_activate,
|
|
524
|
+
"/api/v1/sessions/configure": self._session_configure,
|
|
525
|
+
"/api/v1/sessions/rename": self._session_rename,
|
|
526
|
+
"/api/v1/sessions/archive": self._session_archive,
|
|
527
|
+
"/api/v1/sessions/unarchive": self._session_unarchive,
|
|
528
|
+
"/api/v1/sessions/delete-lazy": self._session_delete_lazy,
|
|
529
|
+
"/api/v1/sessions/delete-materialized": (
|
|
530
|
+
self._session_delete_materialized
|
|
531
|
+
),
|
|
532
|
+
"/api/v1/sessions/stop": self._session_stop,
|
|
533
|
+
"/api/v1/sessions/release": self._session_release,
|
|
534
|
+
"/api/v1/side-topics/close": self._side_close,
|
|
535
|
+
}
|
|
536
|
+
handler = mutations.get(context.path) if context.method == "POST" else None
|
|
537
|
+
if handler is not None:
|
|
538
|
+
return await handler(context)
|
|
539
|
+
raise AdminWebError(404, "not_found", "管理接口不存在。")
|
|
540
|
+
|
|
541
|
+
def _updates_response(
|
|
542
|
+
self, context: _RequestContext, status: dict[str, Any]
|
|
543
|
+
) -> Response:
|
|
544
|
+
payload = dict(status)
|
|
545
|
+
checking_error_code = payload.pop("checkingErrorCode")
|
|
546
|
+
payload["message"] = "程序更新属于 Python 环境级操作,请在对应环境运行 netizen update;管理页只重启当前实例。"
|
|
547
|
+
payload["checkingError"] = _release_check_message(checking_error_code)
|
|
548
|
+
actions: dict[str, object] = {
|
|
549
|
+
"check": None,
|
|
550
|
+
"install": None,
|
|
551
|
+
"restart": None,
|
|
552
|
+
}
|
|
553
|
+
if status.get("restartAvailable") is True:
|
|
554
|
+
actions["restart"] = self._grant(
|
|
555
|
+
context,
|
|
556
|
+
"updates.restart",
|
|
557
|
+
AdminActionTarget("instance-restart", status["current"]["installationId"]),
|
|
558
|
+
_empty_preconditions(),
|
|
559
|
+
)
|
|
560
|
+
return _json_response(
|
|
561
|
+
200,
|
|
562
|
+
{**payload, "requestId": context.request.request_id, "actions": actions},
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
async def _updates_check(self, context: _RequestContext) -> Response:
|
|
566
|
+
raise AdminWebError(409, "update_unsupported", "请在对应 Python 环境运行 netizen update。")
|
|
567
|
+
|
|
568
|
+
async def _updates_install(self, context: _RequestContext) -> Response:
|
|
569
|
+
raise AdminWebError(409, "update_unsupported", "请在对应 Python 环境运行 netizen update。")
|
|
570
|
+
|
|
571
|
+
async def _updates_restart(self, context: _RequestContext) -> Response:
|
|
572
|
+
_require_query_keys(context.query, set())
|
|
573
|
+
_, grant = self._redeem(
|
|
574
|
+
context, "updates.restart", expected_resource="instance-restart"
|
|
575
|
+
)
|
|
576
|
+
operation = await self._mutation(
|
|
577
|
+
context,
|
|
578
|
+
"updates.restart",
|
|
579
|
+
grant.target.target_id,
|
|
580
|
+
self._management.restart_service(installation_id=grant.target.target_id),
|
|
581
|
+
)
|
|
582
|
+
return _json_response(
|
|
583
|
+
202, {"requestId": context.request.request_id, "operation": operation}
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
async def _default_manage(self, request: dict[str, object]) -> dict[str, Any]:
|
|
587
|
+
try:
|
|
588
|
+
return await self._management.defaults.manage(request)
|
|
589
|
+
except DefaultConfigurationError as error:
|
|
590
|
+
status = 409 if error.code in {"revision_conflict", "order_conflict", "not_found"} else 400
|
|
591
|
+
if error.code in {"unavailable", "chat_unavailable", "chat_kind_unknown", "model_catalog_unavailable"}:
|
|
592
|
+
status = 503
|
|
593
|
+
raise AdminWebError(status, error.code, str(error)) from error
|
|
594
|
+
|
|
595
|
+
def _default_record(
|
|
596
|
+
self, context: _RequestContext, rule: dict[str, Any], chat: ChatLabel | None = None,
|
|
597
|
+
) -> dict[str, Any]:
|
|
598
|
+
target = AdminActionTarget("default-rule", rule["id"])
|
|
599
|
+
preconditions = _empty_preconditions(default_revision=ExpectedValue.expect(rule["revision"]))
|
|
600
|
+
result = {**rule, "actions": {
|
|
601
|
+
mode: self._grant(context, f"defaults.{mode}", target, preconditions)
|
|
602
|
+
for mode in ("save", "delete")
|
|
603
|
+
}}
|
|
604
|
+
if chat is not None:
|
|
605
|
+
result["chat"] = {
|
|
606
|
+
"chatId": chat.chat_id,
|
|
607
|
+
"chatLabel": chat.display_name,
|
|
608
|
+
"chatLabelResolved": chat.resolved,
|
|
609
|
+
"chatMode": chat.chat_mode,
|
|
610
|
+
"chatType": chat.chat_type,
|
|
611
|
+
"chatOpenUrl": _chat_open_url(chat),
|
|
612
|
+
}
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
async def _defaults(self, context: _RequestContext) -> Response:
|
|
616
|
+
mode = _optional_one(context.query, "mode") or "list"
|
|
617
|
+
allowed = {
|
|
618
|
+
"list": {"mode", "kind", "offset", "limit"},
|
|
619
|
+
"view": {"mode", "chat_id"},
|
|
620
|
+
"options": {"mode", "chat_id"},
|
|
621
|
+
}
|
|
622
|
+
if mode not in allowed:
|
|
623
|
+
raise AdminWebError(400, "invalid_query", "默认配置查询类型无效。")
|
|
624
|
+
_require_query_keys(context.query, allowed[mode])
|
|
625
|
+
request: dict[str, object] = {"mode": mode}
|
|
626
|
+
for key in allowed[mode] - {"mode"}:
|
|
627
|
+
value = _optional_text_query(context.query, key)
|
|
628
|
+
if value is not None:
|
|
629
|
+
if key in {"offset", "limit"}:
|
|
630
|
+
if not value.isascii() or not value.isdigit():
|
|
631
|
+
raise AdminWebError(400, "invalid_query", "分页参数无效。")
|
|
632
|
+
request[key] = int(value)
|
|
633
|
+
else:
|
|
634
|
+
request[key] = value
|
|
635
|
+
if mode == "list":
|
|
636
|
+
request.setdefault("kind", "chat")
|
|
637
|
+
result = await self._default_manage(request)
|
|
638
|
+
if mode == "list":
|
|
639
|
+
kind = request.get("kind", "chat")
|
|
640
|
+
chats = await self._management.resolve_chat_labels(
|
|
641
|
+
(rule["chat_id"] for rule in result["items"]),
|
|
642
|
+
deadline=asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS,
|
|
643
|
+
) if kind == "chat" else {}
|
|
644
|
+
result = {**result, "items": [
|
|
645
|
+
self._default_record(context, rule, chats.get(rule["chat_id"])) for rule in result["items"]
|
|
646
|
+
],
|
|
647
|
+
"actions": {"create": self._grant(
|
|
648
|
+
context, "defaults.save", AdminActionTarget("default-registry", str(kind)), _empty_preconditions(),
|
|
649
|
+
)}}
|
|
650
|
+
if kind == "group_name":
|
|
651
|
+
result["actions"]["reorder"] = self._grant(
|
|
652
|
+
context, "defaults.reorder", AdminActionTarget("default-registry", "group_name"),
|
|
653
|
+
_empty_preconditions(default_order_revision=ExpectedValue.expect(result["order_revision"])),
|
|
654
|
+
)
|
|
655
|
+
elif mode == "view":
|
|
656
|
+
if result["exact"] is not None:
|
|
657
|
+
result = {**result, "exact": self._default_record(context, result["exact"])}
|
|
658
|
+
result = {**result, "actions": {"create": self._grant(
|
|
659
|
+
context, "defaults.save", AdminActionTarget("default-registry", "chat"), _empty_preconditions(),
|
|
660
|
+
)}}
|
|
661
|
+
return _json_response(200, {**result, "requestId": context.request.request_id})
|
|
662
|
+
|
|
663
|
+
async def _default_save(self, context: _RequestContext) -> Response:
|
|
664
|
+
_require_query_keys(context.query, set())
|
|
665
|
+
payload = _parse_json(context.request)
|
|
666
|
+
_require_body_keys(payload, _ACTION_KEYS | {"definition"})
|
|
667
|
+
target = _action_target(payload)
|
|
668
|
+
if target.resource not in {"default-registry", "default-rule"}:
|
|
669
|
+
raise AdminWebError(400, "invalid_target", "默认配置目标无效。")
|
|
670
|
+
grant = self._redeem_parsed(context, payload, "defaults.save", target, expected_resource=target.resource)
|
|
671
|
+
definition = payload.get("definition")
|
|
672
|
+
if not isinstance(definition, dict) or set(definition) - {
|
|
673
|
+
"kind", "chat_id", "keyword", "project", "session_settings",
|
|
674
|
+
}:
|
|
675
|
+
raise AdminWebError(400, "invalid_input", "默认配置字段无效。")
|
|
676
|
+
request: dict[str, object] = {**definition, "mode": "save", "expected_revision": None}
|
|
677
|
+
if target.resource == "default-rule":
|
|
678
|
+
request.update(id=grant.target.target_id, expected_revision=_expected_value(
|
|
679
|
+
_grant_preconditions(grant).default_revision, "Default revision",
|
|
680
|
+
))
|
|
681
|
+
elif definition.get("kind") != grant.target.target_id:
|
|
682
|
+
raise AdminWebError(400, "invalid_target", "默认配置类型与操作凭据不一致。")
|
|
683
|
+
result = await self._mutation(context, "defaults.save", grant.target.target_id, self._default_manage(request))
|
|
684
|
+
return _json_response(200, {**result, "requestId": context.request.request_id})
|
|
685
|
+
|
|
686
|
+
async def _default_delete(self, context: _RequestContext) -> Response:
|
|
687
|
+
_require_query_keys(context.query, set())
|
|
688
|
+
_, grant = self._redeem(context, "defaults.delete", expected_resource="default-rule")
|
|
689
|
+
request: dict[str, object] = {"mode": "delete", "id": grant.target.target_id,
|
|
690
|
+
"expected_revision": _expected_value(_grant_preconditions(grant).default_revision, "Default revision")}
|
|
691
|
+
result = await self._mutation(context, "defaults.delete", grant.target.target_id, self._default_manage(request))
|
|
692
|
+
return _json_response(200, {**result, "requestId": context.request.request_id})
|
|
693
|
+
|
|
694
|
+
async def _default_reorder(self, context: _RequestContext) -> Response:
|
|
695
|
+
_require_query_keys(context.query, set())
|
|
696
|
+
payload, grant = self._redeem(context, "defaults.reorder", expected_resource="default-registry", allowed_extra={"rule_ids"})
|
|
697
|
+
request: dict[str, object] = {"mode": "reorder", "rule_ids": payload.get("rule_ids"),
|
|
698
|
+
"order_revision": _expected_value(_grant_preconditions(grant).default_order_revision, "Default order revision")}
|
|
699
|
+
result = await self._mutation(context, "defaults.reorder", grant.target.target_id, self._default_manage(request))
|
|
700
|
+
return _json_response(200, {**result, "requestId": context.request.request_id})
|
|
701
|
+
|
|
702
|
+
async def _schedules(self, context: _RequestContext) -> Response:
|
|
703
|
+
mode = _optional_one(context.query, "mode") or "list"
|
|
704
|
+
allowed = {
|
|
705
|
+
"list": {"mode", "chat_id", "project", "enabled", "ended", "cursor"},
|
|
706
|
+
"view": {"mode", "plan_id"},
|
|
707
|
+
"runs": {"mode", "plan_id", "cursor"},
|
|
708
|
+
"preview": {"mode", "schedule", "plan_id", "chat_id", "session_settings", "target_kind", "target_binding_id", "local_at", "utc_offset", "local_end_at", "end_utc_offset"},
|
|
709
|
+
"options": {"mode", "chat_id", "binding_query"},
|
|
710
|
+
}
|
|
711
|
+
if mode not in allowed:
|
|
712
|
+
raise AdminWebError(400, "invalid_query", "定时任务查询类型无效。")
|
|
713
|
+
_require_query_keys(context.query, allowed[mode])
|
|
714
|
+
request: dict[str, object] = {"mode": mode}
|
|
715
|
+
for key in allowed[mode] - {"mode", "enabled", "ended", "schedule", "session_settings"}:
|
|
716
|
+
value = _optional_text_query(context.query, key)
|
|
717
|
+
if value is not None:
|
|
718
|
+
request[key] = value
|
|
719
|
+
if mode == "list":
|
|
720
|
+
request["all"] = True
|
|
721
|
+
for key in ("enabled", "ended"):
|
|
722
|
+
value = _optional_bool_query(context.query, key)
|
|
723
|
+
if value is not None:
|
|
724
|
+
request[key] = value
|
|
725
|
+
if mode == "preview":
|
|
726
|
+
raw = _optional_text_query(context.query, "schedule", maximum=2048)
|
|
727
|
+
try:
|
|
728
|
+
schedule = json.loads(raw or "null")
|
|
729
|
+
except ValueError:
|
|
730
|
+
raise AdminWebError(400, "invalid_query", "时间规则无效。") from None
|
|
731
|
+
if not isinstance(schedule, dict):
|
|
732
|
+
raise AdminWebError(400, "invalid_query", "时间规则无效。")
|
|
733
|
+
preview_request = {key: value for key, value in request.items() if key != "mode"}
|
|
734
|
+
local_at = preview_request.pop("local_at", None)
|
|
735
|
+
utc_offset = preview_request.pop("utc_offset", None)
|
|
736
|
+
local_end_at = preview_request.pop("local_end_at", None)
|
|
737
|
+
end_utc_offset = preview_request.pop("end_utc_offset", None)
|
|
738
|
+
local_fields = (("at", local_at, utc_offset), ("end_at", local_end_at, end_utc_offset))
|
|
739
|
+
previous = None
|
|
740
|
+
if any(local is not None or offset is not None for _, local, offset in local_fields) and "plan_id" in preview_request:
|
|
741
|
+
current = await self._management.schedules.manage(
|
|
742
|
+
{"mode": "view", "plan_id": preview_request["plan_id"]}, source="admin",
|
|
743
|
+
)
|
|
744
|
+
_require_schedule_success(current)
|
|
745
|
+
previous = current["plan"]["schedule"]
|
|
746
|
+
for field, local, offset in local_fields:
|
|
747
|
+
if local is None and offset is None:
|
|
748
|
+
continue
|
|
749
|
+
supported = schedule.get("kind") == "once" if field == "at" else schedule.get("kind") in {"daily", "weekly", "interval"}
|
|
750
|
+
if not supported or local is None or field in schedule:
|
|
751
|
+
raise AdminWebError(400, "invalid_input", "当地执行时间仅用于一次性规则,截止时间仅用于重复规则,且不能与带偏移时间同时指定。")
|
|
752
|
+
original_at = previous.get(field) if previous and previous["timezone"] == schedule.get("timezone") else None
|
|
753
|
+
try:
|
|
754
|
+
schedule[field] = resolve_once_local(
|
|
755
|
+
local, schedule.get("timezone"), original_at=original_at, utc_offset=offset,
|
|
756
|
+
)
|
|
757
|
+
except AmbiguousLocalTime as error:
|
|
758
|
+
return _json_response(400, {"requestId": context.request.request_id,
|
|
759
|
+
"code": "ambiguous_end_time" if field == "end_at" else error.code,
|
|
760
|
+
"message": str(error), "choices": error.choices})
|
|
761
|
+
except ScheduleError as error:
|
|
762
|
+
raise AdminWebError(400, error.code, str(error)) from error
|
|
763
|
+
preview_request["schedule"] = schedule
|
|
764
|
+
settings_raw = _optional_text_query(context.query, "session_settings", maximum=4096)
|
|
765
|
+
if settings_raw is not None:
|
|
766
|
+
try:
|
|
767
|
+
settings = json.loads(settings_raw)
|
|
768
|
+
except ValueError:
|
|
769
|
+
raise AdminWebError(400, "invalid_query", "会话配置无效。") from None
|
|
770
|
+
if not isinstance(settings, dict):
|
|
771
|
+
raise AdminWebError(400, "invalid_query", "会话配置无效。")
|
|
772
|
+
preview_request["session_settings"] = settings
|
|
773
|
+
result = await self._management.schedules.preview(preview_request)
|
|
774
|
+
else:
|
|
775
|
+
result = await self._management.schedules.manage(request, source="admin")
|
|
776
|
+
_require_schedule_success(result)
|
|
777
|
+
if mode == "list":
|
|
778
|
+
chats = await self._management.resolve_chat_labels(
|
|
779
|
+
(plan["chat_id"] for plan in result["plans"]),
|
|
780
|
+
deadline=asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS,
|
|
781
|
+
)
|
|
782
|
+
result = {
|
|
783
|
+
**result,
|
|
784
|
+
"plans": [self._schedule_plan(context, plan, chats[plan["chat_id"]]) for plan in result["plans"]],
|
|
785
|
+
"actions": {"create": self._grant(
|
|
786
|
+
context, "schedules.create", AdminActionTarget("schedule-registry", "schedules"),
|
|
787
|
+
_empty_preconditions(),
|
|
788
|
+
)},
|
|
789
|
+
}
|
|
790
|
+
elif mode == "view":
|
|
791
|
+
plan = result["plan"]
|
|
792
|
+
chats = await self._management.resolve_chat_labels(
|
|
793
|
+
(plan["chat_id"],), deadline=asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS,
|
|
794
|
+
)
|
|
795
|
+
result = {**result, "plan": self._schedule_plan(context, plan, chats[plan["chat_id"]])}
|
|
796
|
+
return _json_response(200, {**result, "requestId": context.request.request_id})
|
|
797
|
+
|
|
798
|
+
def _schedule_plan(self, context: _RequestContext, plan: dict[str, Any], chat: ChatLabel) -> dict[str, Any]:
|
|
799
|
+
target = AdminActionTarget("schedule", plan["id"])
|
|
800
|
+
preconditions = _empty_preconditions(
|
|
801
|
+
schedule_revision=ExpectedValue.expect(plan["revision"]),
|
|
802
|
+
)
|
|
803
|
+
return {**_schedule_plan_json(plan, chat), "actions": {
|
|
804
|
+
mode: self._grant(context, f"schedules.{mode}", target, preconditions)
|
|
805
|
+
if mode != "run_now" or plan.get("can_run_now") is True else None
|
|
806
|
+
for mode in ("update", "delete", "run_now")
|
|
807
|
+
}}
|
|
808
|
+
|
|
809
|
+
async def _schedule_create(self, context: _RequestContext) -> Response:
|
|
810
|
+
return await self._schedule_mutate(context, "create")
|
|
811
|
+
|
|
812
|
+
async def _schedule_update(self, context: _RequestContext) -> Response:
|
|
813
|
+
return await self._schedule_mutate(context, "update")
|
|
814
|
+
|
|
815
|
+
async def _schedule_delete(self, context: _RequestContext) -> Response:
|
|
816
|
+
return await self._schedule_mutate(context, "delete")
|
|
817
|
+
|
|
818
|
+
async def _schedule_run_now(self, context: _RequestContext) -> Response:
|
|
819
|
+
return await self._schedule_mutate(context, "run_now")
|
|
820
|
+
|
|
821
|
+
async def _schedule_mutate(self, context: _RequestContext, mode: str) -> Response:
|
|
822
|
+
_require_query_keys(context.query, set())
|
|
823
|
+
payload, grant = self._redeem(
|
|
824
|
+
context, f"schedules.{mode}",
|
|
825
|
+
expected_resource="schedule-registry" if mode == "create" else "schedule",
|
|
826
|
+
allowed_extra={"definition"} if mode in {"create", "update"} else set(),
|
|
827
|
+
)
|
|
828
|
+
definition = payload.get("definition", {})
|
|
829
|
+
if not isinstance(definition, dict) or set(definition) - {
|
|
830
|
+
"name", "instructions", "project", "chat_id", "schedule", "enabled", "session_settings", "target_kind", "target_binding_id",
|
|
831
|
+
}:
|
|
832
|
+
raise AdminWebError(400, "invalid_input", "定时任务定义字段无效。")
|
|
833
|
+
request: dict[str, object] = {
|
|
834
|
+
**definition,
|
|
835
|
+
"mode": mode,
|
|
836
|
+
# Only an opaque digest crosses into durable management deduplication;
|
|
837
|
+
# browser authentication/action credentials remain process-local.
|
|
838
|
+
"request_id": hashlib.sha256(str(payload["actionToken"]).encode()).hexdigest(),
|
|
839
|
+
}
|
|
840
|
+
if mode != "create":
|
|
841
|
+
request["plan_id"] = grant.target.target_id
|
|
842
|
+
request["expected_revision"] = _expected_value(
|
|
843
|
+
_grant_preconditions(grant).schedule_revision, "Schedule revision",
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
async def operate() -> dict[str, Any]:
|
|
847
|
+
result = await self._management.schedules.manage(request, source="admin")
|
|
848
|
+
_require_schedule_success(result)
|
|
849
|
+
return result
|
|
850
|
+
|
|
851
|
+
result = await self._mutation(
|
|
852
|
+
context, f"schedules.{mode}", grant.target.target_id,
|
|
853
|
+
operate(),
|
|
854
|
+
)
|
|
855
|
+
return _json_response(202 if mode == "run_now" else 200, {**result, "requestId": context.request.request_id})
|
|
856
|
+
|
|
857
|
+
async def _projects(self, context: _RequestContext) -> Response:
|
|
858
|
+
allowed = {"cursor", "pageSize"}
|
|
859
|
+
_require_query_keys(context.query, allowed)
|
|
860
|
+
page_size = _page_size(context.query)
|
|
861
|
+
fingerprint = _fingerprint("projects", {"pageSize": page_size})
|
|
862
|
+
cursor = _decode_project_cursor(_optional_one(context.query, "cursor"), fingerprint)
|
|
863
|
+
deadline = asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS
|
|
864
|
+
page = await self._management.query_projects(
|
|
865
|
+
cursor=cursor,
|
|
866
|
+
limit=page_size,
|
|
867
|
+
deadline=deadline,
|
|
868
|
+
)
|
|
869
|
+
registry_target = AdminActionTarget("project-registry", "new")
|
|
870
|
+
empty = _empty_preconditions()
|
|
871
|
+
actions = {
|
|
872
|
+
"register": self._grant(
|
|
873
|
+
context,
|
|
874
|
+
"projects.register",
|
|
875
|
+
registry_target,
|
|
876
|
+
empty,
|
|
877
|
+
),
|
|
878
|
+
"createDirectory": self._grant(
|
|
879
|
+
context,
|
|
880
|
+
"projects.create-directory",
|
|
881
|
+
registry_target,
|
|
882
|
+
empty,
|
|
883
|
+
),
|
|
884
|
+
}
|
|
885
|
+
items = []
|
|
886
|
+
for item in page.items:
|
|
887
|
+
project = item.aggregate.project
|
|
888
|
+
project_target = AdminActionTarget("project", project.alias)
|
|
889
|
+
preconditions = _empty_preconditions(
|
|
890
|
+
project_revision=ExpectedValue.expect(project.revision)
|
|
891
|
+
)
|
|
892
|
+
project_actions: dict[str, object] = {
|
|
893
|
+
"setEnabled": self._grant(
|
|
894
|
+
context,
|
|
895
|
+
"projects.set-enabled",
|
|
896
|
+
project_target,
|
|
897
|
+
preconditions,
|
|
898
|
+
),
|
|
899
|
+
"previewDelete": self._grant(
|
|
900
|
+
context,
|
|
901
|
+
"projects.delete-preview",
|
|
902
|
+
project_target,
|
|
903
|
+
preconditions,
|
|
904
|
+
),
|
|
905
|
+
}
|
|
906
|
+
if item.deleting:
|
|
907
|
+
project_actions = {}
|
|
908
|
+
items.append(
|
|
909
|
+
{
|
|
910
|
+
"alias": project.alias,
|
|
911
|
+
"cwd": project.cwd,
|
|
912
|
+
"enabled": project.enabled,
|
|
913
|
+
"deleting": item.deleting,
|
|
914
|
+
"revision": project.revision,
|
|
915
|
+
"createdAt": project.created_at,
|
|
916
|
+
"updatedAt": project.updated_at,
|
|
917
|
+
"bindingCount": item.aggregate.binding_count,
|
|
918
|
+
"lazyBindingCount": item.aggregate.lazy_binding_count,
|
|
919
|
+
"materializedBindingCount": item.aggregate.materialized_binding_count,
|
|
920
|
+
"archivedBindingCount": item.archived_binding_count,
|
|
921
|
+
"unconfirmedBindingCount": item.unconfirmed_binding_count,
|
|
922
|
+
"lastActivatedAt": item.aggregate.last_activated_at,
|
|
923
|
+
"actions": project_actions,
|
|
924
|
+
}
|
|
925
|
+
)
|
|
926
|
+
return _json_response(
|
|
927
|
+
200,
|
|
928
|
+
{
|
|
929
|
+
"requestId": context.request.request_id,
|
|
930
|
+
"items": items,
|
|
931
|
+
"nextCursor": _encode_project_cursor(page.next_cursor, fingerprint),
|
|
932
|
+
"actions": actions,
|
|
933
|
+
},
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
async def _project_options(self, context: _RequestContext) -> Response:
|
|
937
|
+
_require_query_keys(context.query, {"cursor", "pageSize"})
|
|
938
|
+
page_size = _page_size(context.query)
|
|
939
|
+
fingerprint = _fingerprint("project-options", {"pageSize": page_size})
|
|
940
|
+
cursor = _decode_project_cursor(_optional_one(context.query, "cursor"), fingerprint)
|
|
941
|
+
page = await self._management.query_project_options(
|
|
942
|
+
cursor=cursor,
|
|
943
|
+
limit=page_size,
|
|
944
|
+
deadline=asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS,
|
|
945
|
+
)
|
|
946
|
+
return _json_response(
|
|
947
|
+
200,
|
|
948
|
+
{
|
|
949
|
+
"requestId": context.request.request_id,
|
|
950
|
+
"items": [
|
|
951
|
+
{"alias": item.project.alias, "enabled": item.project.enabled}
|
|
952
|
+
for item in page.items
|
|
953
|
+
],
|
|
954
|
+
"nextCursor": _encode_project_cursor(page.next_cursor, fingerprint),
|
|
955
|
+
},
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
async def _sessions(self, context: _RequestContext) -> Response:
|
|
959
|
+
allowed = {
|
|
960
|
+
"cursor",
|
|
961
|
+
"pageSize",
|
|
962
|
+
"project",
|
|
963
|
+
"scopeKind",
|
|
964
|
+
"chatId",
|
|
965
|
+
"topicId",
|
|
966
|
+
"identity",
|
|
967
|
+
"inventoryState",
|
|
968
|
+
"current",
|
|
969
|
+
"createdFrom",
|
|
970
|
+
"createdBefore",
|
|
971
|
+
}
|
|
972
|
+
_require_query_keys(context.query, allowed)
|
|
973
|
+
page_size = _session_page_size(context.query)
|
|
974
|
+
created_from, created_before = _created_range_query(context.query)
|
|
975
|
+
local = BindingQuery(
|
|
976
|
+
project_aliases=_text_set_query(context.query, "project"),
|
|
977
|
+
scope_kinds=_scope_kinds_query(context.query),
|
|
978
|
+
chat_id=_optional_text_query(context.query, "chatId"),
|
|
979
|
+
topic_id=_optional_text_query(context.query, "topicId"),
|
|
980
|
+
identity=_optional_text_query(context.query, "identity"),
|
|
981
|
+
current=_current_query(context.query),
|
|
982
|
+
created_from=created_from,
|
|
983
|
+
created_before=created_before,
|
|
984
|
+
)
|
|
985
|
+
inventory_states = _session_inventory_states(context.query)
|
|
986
|
+
filter_values = {
|
|
987
|
+
"pageSize": page_size,
|
|
988
|
+
"local": _jsonable(local),
|
|
989
|
+
"inventoryState": (
|
|
990
|
+
tuple(state.value for state in inventory_states)
|
|
991
|
+
if inventory_states is not None
|
|
992
|
+
else "all"
|
|
993
|
+
),
|
|
994
|
+
}
|
|
995
|
+
fingerprint = _fingerprint("sessions", filter_values)
|
|
996
|
+
cursor = _decode_binding_cursor(_optional_one(context.query, "cursor"), fingerprint)
|
|
997
|
+
deadline = (
|
|
998
|
+
asyncio.get_running_loop().time() + _SESSION_QUERY_DEADLINE_SECONDS
|
|
999
|
+
)
|
|
1000
|
+
page = await self._management.query_sessions(
|
|
1001
|
+
query=SessionQuery(
|
|
1002
|
+
local=local,
|
|
1003
|
+
inventory_states=inventory_states,
|
|
1004
|
+
),
|
|
1005
|
+
cursor=cursor,
|
|
1006
|
+
limit=page_size,
|
|
1007
|
+
deadline=deadline,
|
|
1008
|
+
)
|
|
1009
|
+
binding_ids = tuple(item.record.binding.id for item in page.items)
|
|
1010
|
+
catalog_states = {
|
|
1011
|
+
item.record.binding.id: (
|
|
1012
|
+
item.native.state if item.native is not None else None
|
|
1013
|
+
)
|
|
1014
|
+
for item in page.items
|
|
1015
|
+
}
|
|
1016
|
+
snapshots_by_id = {}
|
|
1017
|
+
for batch in _batches(binding_ids, _RUNTIME_SNAPSHOT_BATCH_SIZE):
|
|
1018
|
+
snapshots = await self._management.binding_statuses_exact(
|
|
1019
|
+
binding_ids=batch,
|
|
1020
|
+
catalog_states={item: catalog_states[item] for item in batch},
|
|
1021
|
+
deadline=deadline,
|
|
1022
|
+
)
|
|
1023
|
+
snapshots_by_id.update(
|
|
1024
|
+
(snapshot.binding_id, snapshot) for snapshot in snapshots
|
|
1025
|
+
)
|
|
1026
|
+
items = [
|
|
1027
|
+
self._session_item(context, item, snapshots_by_id[item.record.binding.id])
|
|
1028
|
+
for item in page.items
|
|
1029
|
+
]
|
|
1030
|
+
return _json_response(
|
|
1031
|
+
200,
|
|
1032
|
+
{
|
|
1033
|
+
"requestId": context.request.request_id,
|
|
1034
|
+
"items": items,
|
|
1035
|
+
"nextCursor": _encode_binding_cursor(page.next_cursor, fingerprint),
|
|
1036
|
+
"pageSize": page_size,
|
|
1037
|
+
"catalogAvailable": page.catalog_available,
|
|
1038
|
+
},
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
def _session_item(
|
|
1042
|
+
self,
|
|
1043
|
+
context: _RequestContext,
|
|
1044
|
+
item: SessionInventoryItem,
|
|
1045
|
+
status: BindingStatusProjection,
|
|
1046
|
+
) -> dict[str, object]:
|
|
1047
|
+
binding = item.record.binding
|
|
1048
|
+
scope = item.record.scope
|
|
1049
|
+
runtime = status.snapshot
|
|
1050
|
+
target = AdminActionTarget("binding", binding.id, binding.scope_key)
|
|
1051
|
+
active_expected = (
|
|
1052
|
+
ExpectedValue.expect_none()
|
|
1053
|
+
if scope.active_binding_id is None
|
|
1054
|
+
else ExpectedValue.expect(scope.active_binding_id)
|
|
1055
|
+
)
|
|
1056
|
+
native_expected = (
|
|
1057
|
+
ExpectedValue.expect_none()
|
|
1058
|
+
if binding.native_thread_id is None
|
|
1059
|
+
else ExpectedValue.expect(binding.native_thread_id)
|
|
1060
|
+
)
|
|
1061
|
+
physical_turn_id = runtime.turn.turn_id if runtime.turn is not None else None
|
|
1062
|
+
preconditions = _empty_preconditions(
|
|
1063
|
+
active_binding_id=active_expected,
|
|
1064
|
+
settings_revision=ExpectedValue.expect(binding.settings_revision),
|
|
1065
|
+
native_thread_id=native_expected,
|
|
1066
|
+
activity_revision=ExpectedValue.expect(runtime.activity_revision),
|
|
1067
|
+
physical_turn_id=(
|
|
1068
|
+
ExpectedValue.expect_none()
|
|
1069
|
+
if physical_turn_id is None
|
|
1070
|
+
else ExpectedValue.expect(physical_turn_id)
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
lifecycle_preconditions = _empty_preconditions()
|
|
1074
|
+
delete_preconditions = _empty_preconditions(
|
|
1075
|
+
native_thread_id=native_expected,
|
|
1076
|
+
)
|
|
1077
|
+
actions: dict[str, object] = {
|
|
1078
|
+
"configure": self._grant(
|
|
1079
|
+
context, "sessions.configure", target, preconditions
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
native_state = item.native.state if item.native is not None else None
|
|
1083
|
+
if (
|
|
1084
|
+
binding.message_context_mode is MentionContextMode.CURRENT_ONLY
|
|
1085
|
+
and status.pointer_state != "current"
|
|
1086
|
+
and (
|
|
1087
|
+
binding.native_thread_id is None
|
|
1088
|
+
or native_state is NativeThreadCatalogState.ACTIVE
|
|
1089
|
+
)
|
|
1090
|
+
):
|
|
1091
|
+
actions["activate"] = self._grant(
|
|
1092
|
+
context, "sessions.activate", target, preconditions
|
|
1093
|
+
)
|
|
1094
|
+
if binding.native_thread_id is None:
|
|
1095
|
+
actions["deleteLazy"] = self._grant(
|
|
1096
|
+
context, "sessions.delete-lazy", target, preconditions
|
|
1097
|
+
)
|
|
1098
|
+
elif native_state is NativeThreadCatalogState.ACTIVE:
|
|
1099
|
+
actions["rename"] = self._grant(
|
|
1100
|
+
context, "sessions.rename", target, preconditions
|
|
1101
|
+
)
|
|
1102
|
+
actions["archive"] = self._grant(
|
|
1103
|
+
context,
|
|
1104
|
+
"sessions.archive",
|
|
1105
|
+
target,
|
|
1106
|
+
lifecycle_preconditions,
|
|
1107
|
+
)
|
|
1108
|
+
if status.can_release:
|
|
1109
|
+
actions["release"] = self._grant(
|
|
1110
|
+
context, "sessions.release", target, preconditions
|
|
1111
|
+
)
|
|
1112
|
+
elif native_state is NativeThreadCatalogState.ARCHIVED:
|
|
1113
|
+
actions["unarchive"] = self._grant(
|
|
1114
|
+
context, "sessions.unarchive", target, preconditions
|
|
1115
|
+
)
|
|
1116
|
+
if binding.message_context_mode is MentionContextMode.CURRENT_ONLY:
|
|
1117
|
+
actions["unarchiveCurrent"] = self._grant(
|
|
1118
|
+
context, "sessions.unarchive-current", target, preconditions
|
|
1119
|
+
)
|
|
1120
|
+
if (
|
|
1121
|
+
self._management.native_delete_available
|
|
1122
|
+
and native_state
|
|
1123
|
+
in {
|
|
1124
|
+
NativeThreadCatalogState.ACTIVE,
|
|
1125
|
+
NativeThreadCatalogState.ARCHIVED,
|
|
1126
|
+
}
|
|
1127
|
+
):
|
|
1128
|
+
actions["deleteMaterialized"] = self._grant(
|
|
1129
|
+
context,
|
|
1130
|
+
"sessions.delete-materialized",
|
|
1131
|
+
target,
|
|
1132
|
+
delete_preconditions,
|
|
1133
|
+
)
|
|
1134
|
+
if status.can_stop:
|
|
1135
|
+
actions["stop"] = self._grant(
|
|
1136
|
+
context, "sessions.stop", target, preconditions
|
|
1137
|
+
)
|
|
1138
|
+
actions["createLazy"] = self._grant(
|
|
1139
|
+
context,
|
|
1140
|
+
"sessions.create-lazy",
|
|
1141
|
+
AdminActionTarget("scope", binding.scope_key, binding.scope_key),
|
|
1142
|
+
preconditions,
|
|
1143
|
+
)
|
|
1144
|
+
metadata = item.native.metadata if item.native is not None else None
|
|
1145
|
+
return {
|
|
1146
|
+
"bindingId": binding.id,
|
|
1147
|
+
"shortId": binding.short_id,
|
|
1148
|
+
"scopeKey": binding.scope_key,
|
|
1149
|
+
"scopeKind": scope.kind.value,
|
|
1150
|
+
"appId": scope.app_id,
|
|
1151
|
+
"chatId": scope.chat_id,
|
|
1152
|
+
"chatLabel": item.chat.display_name,
|
|
1153
|
+
"chatLabelResolved": item.chat.resolved,
|
|
1154
|
+
"chatMode": item.chat.chat_mode,
|
|
1155
|
+
"chatType": item.chat.chat_type,
|
|
1156
|
+
"chatOpenUrl": _chat_open_url(item.chat),
|
|
1157
|
+
"topicOpenUrl": (
|
|
1158
|
+
_topic_open_url(scope.chat_id, scope.topic_id)
|
|
1159
|
+
if scope.kind is ScopeKind.TOPIC and scope.topic_id
|
|
1160
|
+
else None
|
|
1161
|
+
),
|
|
1162
|
+
"topicId": scope.topic_id,
|
|
1163
|
+
"sessionType": (
|
|
1164
|
+
"topic" if scope.kind is ScopeKind.TOPIC else "message"
|
|
1165
|
+
),
|
|
1166
|
+
"pointerState": status.pointer_state,
|
|
1167
|
+
"catalogState": item.inventory_state.value,
|
|
1168
|
+
"projectAlias": binding.project_alias,
|
|
1169
|
+
"nativeThreadId": binding.native_thread_id,
|
|
1170
|
+
"nativeTitle": metadata.name if metadata is not None else None,
|
|
1171
|
+
"nativePreview": metadata.preview if metadata is not None else None,
|
|
1172
|
+
"updatedAt": metadata.updated_at if metadata is not None else None,
|
|
1173
|
+
"creator": binding.creator_id,
|
|
1174
|
+
"createdAt": binding.created_at,
|
|
1175
|
+
"activatedAt": binding.activated_at,
|
|
1176
|
+
"settingsRevision": binding.settings_revision,
|
|
1177
|
+
"turnSettings": _settings_json(binding.turn_settings),
|
|
1178
|
+
"messageContextMode": binding.message_context_mode.value,
|
|
1179
|
+
"contextRevision": binding.context_revision,
|
|
1180
|
+
"runtime": _runtime_binding_json(status),
|
|
1181
|
+
"actions": actions,
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async def _runtime_snapshots(self, context: _RequestContext) -> Response:
|
|
1185
|
+
_require_query_keys(
|
|
1186
|
+
context.query,
|
|
1187
|
+
{"bindingIds", "sideIds", "resolvePrimary"},
|
|
1188
|
+
)
|
|
1189
|
+
binding_ids = _id_query(context.query, "bindingIds")
|
|
1190
|
+
side_ids = _id_query(context.query, "sideIds")
|
|
1191
|
+
if len(binding_ids) + len(side_ids) > _RUNTIME_SNAPSHOT_BATCH_SIZE:
|
|
1192
|
+
raise ValueError("runtime snapshot request accepts at most 50 IDs")
|
|
1193
|
+
resolve_primary = (
|
|
1194
|
+
_optional_bool_query(context.query, "resolvePrimary") or False
|
|
1195
|
+
)
|
|
1196
|
+
if resolve_primary:
|
|
1197
|
+
bindings = await self._management.binding_statuses_exact(
|
|
1198
|
+
binding_ids=binding_ids,
|
|
1199
|
+
deadline=(
|
|
1200
|
+
asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS
|
|
1201
|
+
),
|
|
1202
|
+
)
|
|
1203
|
+
side_snapshots = self._management.runtime_snapshots(side_ids=side_ids)
|
|
1204
|
+
else:
|
|
1205
|
+
snapshots = self._management.runtime_snapshots(
|
|
1206
|
+
binding_ids=binding_ids,
|
|
1207
|
+
side_ids=side_ids,
|
|
1208
|
+
)
|
|
1209
|
+
bindings = snapshots.bindings
|
|
1210
|
+
side_snapshots = snapshots
|
|
1211
|
+
return _json_response(
|
|
1212
|
+
200,
|
|
1213
|
+
{
|
|
1214
|
+
"requestId": context.request.request_id,
|
|
1215
|
+
"bindings": [
|
|
1216
|
+
_runtime_binding_json(snapshot) for snapshot in bindings
|
|
1217
|
+
],
|
|
1218
|
+
"sides": [
|
|
1219
|
+
_runtime_side_json(snapshot) for snapshot in side_snapshots.sides
|
|
1220
|
+
],
|
|
1221
|
+
"missingSideIds": list(side_snapshots.missing_side_ids),
|
|
1222
|
+
},
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
async def _side_topics(self, context: _RequestContext) -> Response:
|
|
1226
|
+
allowed = {
|
|
1227
|
+
"cursor",
|
|
1228
|
+
"pageSize",
|
|
1229
|
+
"project",
|
|
1230
|
+
"parentBindingId",
|
|
1231
|
+
"appId",
|
|
1232
|
+
"chatId",
|
|
1233
|
+
"topicId",
|
|
1234
|
+
"rootMessageId",
|
|
1235
|
+
"state",
|
|
1236
|
+
"createdFrom",
|
|
1237
|
+
"createdBefore",
|
|
1238
|
+
}
|
|
1239
|
+
_require_query_keys(context.query, allowed)
|
|
1240
|
+
page_size = _page_size(context.query)
|
|
1241
|
+
state_value = _optional_text_query(context.query, "state")
|
|
1242
|
+
try:
|
|
1243
|
+
state = SideTopicState(state_value) if state_value is not None else None
|
|
1244
|
+
except ValueError:
|
|
1245
|
+
raise AdminWebError(400, "invalid_state", "Side 状态筛选无效。") from None
|
|
1246
|
+
created_from, created_before = _created_range_query(context.query)
|
|
1247
|
+
query = SideTopicQuery(
|
|
1248
|
+
project_alias=_optional_text_query(context.query, "project"),
|
|
1249
|
+
parent_binding_id=_optional_text_query(context.query, "parentBindingId"),
|
|
1250
|
+
app_id=_optional_text_query(context.query, "appId"),
|
|
1251
|
+
chat_id=_optional_text_query(context.query, "chatId"),
|
|
1252
|
+
topic_id=_optional_text_query(context.query, "topicId"),
|
|
1253
|
+
root_message_id=_optional_text_query(context.query, "rootMessageId"),
|
|
1254
|
+
state=state,
|
|
1255
|
+
created_from=created_from,
|
|
1256
|
+
created_before=created_before,
|
|
1257
|
+
)
|
|
1258
|
+
fingerprint = _fingerprint(
|
|
1259
|
+
"side-topics",
|
|
1260
|
+
{"pageSize": page_size, "query": _jsonable(query)},
|
|
1261
|
+
)
|
|
1262
|
+
cursor = _decode_side_cursor(_optional_one(context.query, "cursor"), fingerprint)
|
|
1263
|
+
page = await self._management.query_side_topics(
|
|
1264
|
+
query=query,
|
|
1265
|
+
cursor=cursor,
|
|
1266
|
+
limit=page_size,
|
|
1267
|
+
deadline=asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS,
|
|
1268
|
+
)
|
|
1269
|
+
items = []
|
|
1270
|
+
for item in page.items:
|
|
1271
|
+
side = item.record.side_topic
|
|
1272
|
+
actions: dict[str, object] = {}
|
|
1273
|
+
if side.state is SideTopicState.OPEN and item.runtime is not None:
|
|
1274
|
+
target = AdminActionTarget("side", side.id, None)
|
|
1275
|
+
preconditions = _empty_preconditions(
|
|
1276
|
+
side_app_id=ExpectedValue.expect(side.app_id),
|
|
1277
|
+
side_chat_id=ExpectedValue.expect(side.chat_id),
|
|
1278
|
+
side_topic_id=(
|
|
1279
|
+
ExpectedValue.expect_none()
|
|
1280
|
+
if side.topic_id is None
|
|
1281
|
+
else ExpectedValue.expect(side.topic_id)
|
|
1282
|
+
),
|
|
1283
|
+
side_root_message_id=(
|
|
1284
|
+
ExpectedValue.expect_none()
|
|
1285
|
+
if side.root_message_id is None
|
|
1286
|
+
else ExpectedValue.expect(side.root_message_id)
|
|
1287
|
+
),
|
|
1288
|
+
)
|
|
1289
|
+
actions["close"] = self._grant(
|
|
1290
|
+
context,
|
|
1291
|
+
"side-topics.close",
|
|
1292
|
+
target,
|
|
1293
|
+
preconditions,
|
|
1294
|
+
)
|
|
1295
|
+
items.append(
|
|
1296
|
+
{
|
|
1297
|
+
"sideId": side.id,
|
|
1298
|
+
"parentBindingId": side.parent_binding_id,
|
|
1299
|
+
"projectAlias": item.record.project_alias,
|
|
1300
|
+
"appId": side.app_id,
|
|
1301
|
+
"chatId": side.chat_id,
|
|
1302
|
+
"chatLabel": item.chat.display_name,
|
|
1303
|
+
"chatLabelResolved": item.chat.resolved,
|
|
1304
|
+
"chatMode": item.chat.chat_mode,
|
|
1305
|
+
"chatType": item.chat.chat_type,
|
|
1306
|
+
"topicId": side.topic_id,
|
|
1307
|
+
"rootMessageId": side.root_message_id,
|
|
1308
|
+
"sourceMessageId": side.source_message_id,
|
|
1309
|
+
"state": side.state.value,
|
|
1310
|
+
"createdAt": side.created_at,
|
|
1311
|
+
"updatedAt": side.updated_at,
|
|
1312
|
+
"runtime": (
|
|
1313
|
+
_runtime_side_json(item.runtime)
|
|
1314
|
+
if item.runtime is not None
|
|
1315
|
+
else None
|
|
1316
|
+
),
|
|
1317
|
+
"actions": actions,
|
|
1318
|
+
}
|
|
1319
|
+
)
|
|
1320
|
+
return _json_response(
|
|
1321
|
+
200,
|
|
1322
|
+
{
|
|
1323
|
+
"requestId": context.request.request_id,
|
|
1324
|
+
"items": items,
|
|
1325
|
+
"nextCursor": _encode_side_cursor(page.next_cursor, fingerprint),
|
|
1326
|
+
},
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
async def _project_register(self, context: _RequestContext) -> Response:
|
|
1330
|
+
payload, _grant = self._redeem(
|
|
1331
|
+
context,
|
|
1332
|
+
"projects.register",
|
|
1333
|
+
expected_resource="project-registry",
|
|
1334
|
+
allowed_extra={"alias", "path"},
|
|
1335
|
+
)
|
|
1336
|
+
alias = _required_text(payload, "alias", maximum=64)
|
|
1337
|
+
path = _required_text(payload, "path", maximum=4_096)
|
|
1338
|
+
project = await self._mutation(
|
|
1339
|
+
context,
|
|
1340
|
+
"projects.register",
|
|
1341
|
+
"project-registry",
|
|
1342
|
+
self._management.register_project(
|
|
1343
|
+
alias=alias,
|
|
1344
|
+
path=path,
|
|
1345
|
+
create_directory=False,
|
|
1346
|
+
deadline=asyncio.get_running_loop().time()
|
|
1347
|
+
+ _MUTATION_DEADLINE_SECONDS,
|
|
1348
|
+
),
|
|
1349
|
+
)
|
|
1350
|
+
return _json_response(
|
|
1351
|
+
200,
|
|
1352
|
+
_project_result(context.request.request_id, project),
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
async def _project_create_directory(
|
|
1356
|
+
self,
|
|
1357
|
+
context: _RequestContext,
|
|
1358
|
+
) -> Response:
|
|
1359
|
+
payload, _grant = self._redeem(
|
|
1360
|
+
context,
|
|
1361
|
+
"projects.create-directory",
|
|
1362
|
+
expected_resource="project-registry",
|
|
1363
|
+
allowed_extra={"alias", "path"},
|
|
1364
|
+
)
|
|
1365
|
+
alias = _required_text(payload, "alias", maximum=64)
|
|
1366
|
+
path = _optional_text_body(payload, "path", maximum=4_096)
|
|
1367
|
+
project = await self._mutation(
|
|
1368
|
+
context,
|
|
1369
|
+
"projects.create-directory",
|
|
1370
|
+
"project-registry",
|
|
1371
|
+
self._management.register_project(
|
|
1372
|
+
alias=alias,
|
|
1373
|
+
path=path,
|
|
1374
|
+
create_directory=True,
|
|
1375
|
+
deadline=asyncio.get_running_loop().time()
|
|
1376
|
+
+ _MUTATION_DEADLINE_SECONDS,
|
|
1377
|
+
),
|
|
1378
|
+
)
|
|
1379
|
+
return _json_response(
|
|
1380
|
+
200,
|
|
1381
|
+
_project_result(context.request.request_id, project),
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
async def _project_set_enabled(self, context: _RequestContext) -> Response:
|
|
1385
|
+
payload, grant = self._redeem(
|
|
1386
|
+
context,
|
|
1387
|
+
"projects.set-enabled",
|
|
1388
|
+
expected_resource="project",
|
|
1389
|
+
allowed_extra={"enabled"},
|
|
1390
|
+
)
|
|
1391
|
+
enabled = _required_bool(payload, "enabled")
|
|
1392
|
+
preconditions = _grant_preconditions(grant)
|
|
1393
|
+
revision = _expected_value(preconditions.project_revision, "Project revision")
|
|
1394
|
+
project = await self._mutation(
|
|
1395
|
+
context,
|
|
1396
|
+
"projects.set-enabled",
|
|
1397
|
+
grant.target.target_id,
|
|
1398
|
+
self._management.set_project_enabled(
|
|
1399
|
+
alias=grant.target.target_id,
|
|
1400
|
+
enabled=enabled,
|
|
1401
|
+
expected_revision=revision,
|
|
1402
|
+
),
|
|
1403
|
+
)
|
|
1404
|
+
return _json_response(
|
|
1405
|
+
200,
|
|
1406
|
+
_project_result(context.request.request_id, project),
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
async def _project_delete_preview(self, context: _RequestContext) -> Response:
|
|
1410
|
+
_require_query_keys(context.query, set())
|
|
1411
|
+
_, grant = self._redeem(
|
|
1412
|
+
context, "projects.delete-preview", expected_resource="project",
|
|
1413
|
+
)
|
|
1414
|
+
snapshot = await self._management.preview_project_delete(
|
|
1415
|
+
alias=grant.target.target_id,
|
|
1416
|
+
expected_revision=_expected_value(
|
|
1417
|
+
_grant_preconditions(grant).project_revision, "Project revision",
|
|
1418
|
+
),
|
|
1419
|
+
deadline=asyncio.get_running_loop().time() + _QUERY_DEADLINE_SECONDS,
|
|
1420
|
+
)
|
|
1421
|
+
project = snapshot.project
|
|
1422
|
+
lazy_count = sum(item.binding.native_thread_id is None for item in snapshot.bindings)
|
|
1423
|
+
return _json_response(200, {
|
|
1424
|
+
"requestId": context.request.request_id,
|
|
1425
|
+
"project": {"alias": project.alias, "cwd": project.cwd, "revision": project.revision},
|
|
1426
|
+
"sessionCount": len(snapshot.bindings),
|
|
1427
|
+
"lazySessionCount": lazy_count,
|
|
1428
|
+
"materializedSessionCount": len(snapshot.bindings) - lazy_count,
|
|
1429
|
+
"sideCount": len(snapshot.sides),
|
|
1430
|
+
"scheduledPlanCount": len(snapshot.scheduled_plans),
|
|
1431
|
+
"scheduledRunCount": len(snapshot.scheduled_runs),
|
|
1432
|
+
"actions": {"delete": self._grant(
|
|
1433
|
+
context, "projects.delete", AdminActionTarget("project", project.alias),
|
|
1434
|
+
_empty_preconditions(
|
|
1435
|
+
project_revision=ExpectedValue.expect(project.revision),
|
|
1436
|
+
inventory_fingerprint=ExpectedValue.expect(snapshot.fingerprint),
|
|
1437
|
+
),
|
|
1438
|
+
)},
|
|
1439
|
+
})
|
|
1440
|
+
|
|
1441
|
+
async def _project_delete(self, context: _RequestContext) -> Response:
|
|
1442
|
+
_require_query_keys(context.query, set())
|
|
1443
|
+
_, grant = self._redeem(context, "projects.delete", expected_resource="project")
|
|
1444
|
+
preconditions = _grant_preconditions(grant)
|
|
1445
|
+
result = await self._mutation(
|
|
1446
|
+
context, "projects.delete", grant.target.target_id,
|
|
1447
|
+
self._management.delete_project(
|
|
1448
|
+
alias=grant.target.target_id,
|
|
1449
|
+
expected_revision=_expected_value(preconditions.project_revision, "Project revision"),
|
|
1450
|
+
expected_inventory_fingerprint=_expected_value(
|
|
1451
|
+
preconditions.inventory_fingerprint, "Project inventory",
|
|
1452
|
+
),
|
|
1453
|
+
),
|
|
1454
|
+
)
|
|
1455
|
+
messages = {
|
|
1456
|
+
"outcome_unknown": "有会话的删除结果未确认,请先对账;本次操作不会自动重试。",
|
|
1457
|
+
"deadline_exceeded": "删除等待已到期,正在处理的目标可能尚未确认;请先查看剩余会话状态。",
|
|
1458
|
+
"inventory_changed": "关联会话在执行期间发生变化,请查看剩余项后重新确认。",
|
|
1459
|
+
"side_close_failed": "关联 Side 的关闭未完成,请先处理该 Side。",
|
|
1460
|
+
"side_creation_in_progress": "关联 Side 正在创建飞书话题,请等待创建完成后刷新并重新确认。",
|
|
1461
|
+
"schedule_creation_in_progress": "关联定时任务仍在创建话题或执行交接结果未决,请查看剩余记录后重新确认。",
|
|
1462
|
+
"delete_failed": "删除未全部完成,请查看剩余会话状态后重新确认。",
|
|
1463
|
+
}
|
|
1464
|
+
if result.deleted:
|
|
1465
|
+
message = f"已删除 Project {result.project_alias} 及关联 Sessions,代码目录已保留。"
|
|
1466
|
+
else:
|
|
1467
|
+
message = (
|
|
1468
|
+
f"Project {result.project_alias} 已保留并停用。"
|
|
1469
|
+
f"已清理 {result.deleted_session_count} 个 Sessions,"
|
|
1470
|
+
f"剩余 {len(result.remaining_sessions)} 个 Sessions、{result.remaining_side_count} 个 Side。"
|
|
1471
|
+
+ messages[result.code]
|
|
1472
|
+
)
|
|
1473
|
+
if result.deleted_plan_count:
|
|
1474
|
+
message += f"已删除 {result.deleted_plan_count} 个定时计划;后续会话清理失败也不会恢复计划。"
|
|
1475
|
+
return _json_response(200, {
|
|
1476
|
+
"requestId": context.request.request_id,
|
|
1477
|
+
"deleted": result.deleted,
|
|
1478
|
+
"projectAlias": result.project_alias,
|
|
1479
|
+
"deletedSessionCount": result.deleted_session_count,
|
|
1480
|
+
"remainingSessionCount": len(result.remaining_sessions),
|
|
1481
|
+
"remainingSessions": [
|
|
1482
|
+
{"bindingId": binding.id, "shortId": binding.short_id, "scopeKey": binding.scope_key}
|
|
1483
|
+
for binding in result.remaining_sessions
|
|
1484
|
+
],
|
|
1485
|
+
"remainingSideCount": result.remaining_side_count,
|
|
1486
|
+
"deletedPlanCount": result.deleted_plan_count,
|
|
1487
|
+
"remainingScheduledRunCount": result.remaining_scheduled_run_count,
|
|
1488
|
+
"failedBindingId": result.failed_binding_id,
|
|
1489
|
+
"failedSideId": result.failed_side_id,
|
|
1490
|
+
"code": result.code,
|
|
1491
|
+
"message": message,
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
async def _session_create_lazy(self, context: _RequestContext) -> Response:
|
|
1495
|
+
payload, grant = self._redeem(
|
|
1496
|
+
context,
|
|
1497
|
+
"sessions.create-lazy",
|
|
1498
|
+
expected_resource="scope",
|
|
1499
|
+
allowed_extra={
|
|
1500
|
+
"projectAlias",
|
|
1501
|
+
"projectRevision",
|
|
1502
|
+
"activate",
|
|
1503
|
+
"turnSettings",
|
|
1504
|
+
},
|
|
1505
|
+
)
|
|
1506
|
+
project_alias = _required_text(payload, "projectAlias", maximum=64)
|
|
1507
|
+
project_revision = _required_int(payload, "projectRevision", minimum=1)
|
|
1508
|
+
activate = _required_bool(payload, "activate")
|
|
1509
|
+
settings = await self._validated_settings(payload.get("turnSettings"))
|
|
1510
|
+
preconditions = _grant_preconditions(grant)
|
|
1511
|
+
created = await self._mutation(
|
|
1512
|
+
context,
|
|
1513
|
+
"sessions.create-lazy",
|
|
1514
|
+
grant.target.target_id,
|
|
1515
|
+
self._management.create_exact_lazy_binding(
|
|
1516
|
+
scope_key=grant.target.target_id,
|
|
1517
|
+
project_alias=project_alias,
|
|
1518
|
+
expected_project_revision=project_revision,
|
|
1519
|
+
expected_active_binding_id=_expected_optional(
|
|
1520
|
+
preconditions.active_binding_id,
|
|
1521
|
+
"active Binding",
|
|
1522
|
+
),
|
|
1523
|
+
activate=activate,
|
|
1524
|
+
turn_settings=settings,
|
|
1525
|
+
deadline=asyncio.get_running_loop().time()
|
|
1526
|
+
+ _MUTATION_DEADLINE_SECONDS,
|
|
1527
|
+
),
|
|
1528
|
+
)
|
|
1529
|
+
return _json_response(
|
|
1530
|
+
200,
|
|
1531
|
+
{
|
|
1532
|
+
"requestId": context.request.request_id,
|
|
1533
|
+
"bindingId": created.binding.id,
|
|
1534
|
+
"scopeKey": created.binding.scope_key,
|
|
1535
|
+
"projectAlias": created.project.alias,
|
|
1536
|
+
"current": created.binding.active,
|
|
1537
|
+
},
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
async def _session_activate(self, context: _RequestContext) -> Response:
|
|
1541
|
+
_payload, grant = self._redeem(
|
|
1542
|
+
context,
|
|
1543
|
+
"sessions.activate",
|
|
1544
|
+
expected_resource="binding",
|
|
1545
|
+
)
|
|
1546
|
+
binding = await self._mutation(
|
|
1547
|
+
context,
|
|
1548
|
+
"sessions.activate",
|
|
1549
|
+
grant.target.target_id,
|
|
1550
|
+
self._management.activate_exact_binding(
|
|
1551
|
+
target=_binding_target(grant)
|
|
1552
|
+
),
|
|
1553
|
+
)
|
|
1554
|
+
return _json_response(
|
|
1555
|
+
200,
|
|
1556
|
+
_binding_result(context.request.request_id, binding),
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
async def _session_configure(self, context: _RequestContext) -> Response:
|
|
1560
|
+
payload, grant = self._redeem(
|
|
1561
|
+
context,
|
|
1562
|
+
"sessions.configure",
|
|
1563
|
+
expected_resource="binding",
|
|
1564
|
+
allowed_extra={"turnSettings"},
|
|
1565
|
+
)
|
|
1566
|
+
settings = await self._validated_settings(payload.get("turnSettings"))
|
|
1567
|
+
preconditions = _grant_preconditions(grant)
|
|
1568
|
+
revision = _expected_value(
|
|
1569
|
+
preconditions.settings_revision,
|
|
1570
|
+
"Binding settings revision",
|
|
1571
|
+
)
|
|
1572
|
+
binding = await self._mutation(
|
|
1573
|
+
context,
|
|
1574
|
+
"sessions.configure",
|
|
1575
|
+
grant.target.target_id,
|
|
1576
|
+
self._management.configure_exact_binding(
|
|
1577
|
+
target=_binding_target(grant),
|
|
1578
|
+
expected_settings_revision=revision,
|
|
1579
|
+
settings=settings,
|
|
1580
|
+
),
|
|
1581
|
+
)
|
|
1582
|
+
return _json_response(
|
|
1583
|
+
200,
|
|
1584
|
+
_binding_result(context.request.request_id, binding),
|
|
1585
|
+
)
|
|
1586
|
+
|
|
1587
|
+
async def _session_rename(self, context: _RequestContext) -> Response:
|
|
1588
|
+
payload, grant = self._redeem(
|
|
1589
|
+
context,
|
|
1590
|
+
"sessions.rename",
|
|
1591
|
+
expected_resource="binding",
|
|
1592
|
+
allowed_extra={"name"},
|
|
1593
|
+
)
|
|
1594
|
+
name = _required_text(payload, "name", maximum=120)
|
|
1595
|
+
renamed = await self._mutation(
|
|
1596
|
+
context,
|
|
1597
|
+
"sessions.rename",
|
|
1598
|
+
grant.target.target_id,
|
|
1599
|
+
self._management.rename_exact_binding(
|
|
1600
|
+
target=_binding_target(grant),
|
|
1601
|
+
name=name,
|
|
1602
|
+
),
|
|
1603
|
+
)
|
|
1604
|
+
return _json_response(
|
|
1605
|
+
200,
|
|
1606
|
+
{
|
|
1607
|
+
"requestId": context.request.request_id,
|
|
1608
|
+
"bindingId": renamed.binding.id,
|
|
1609
|
+
"name": renamed.name,
|
|
1610
|
+
},
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
async def _session_archive(self, context: _RequestContext) -> Response:
|
|
1614
|
+
_payload, grant = self._redeem(
|
|
1615
|
+
context,
|
|
1616
|
+
"sessions.archive",
|
|
1617
|
+
expected_resource="binding",
|
|
1618
|
+
)
|
|
1619
|
+
binding = await self._mutation(
|
|
1620
|
+
context,
|
|
1621
|
+
"sessions.archive",
|
|
1622
|
+
grant.target.target_id,
|
|
1623
|
+
self._management.archive_exact_binding(
|
|
1624
|
+
target=_lifecycle_binding_target(grant),
|
|
1625
|
+
),
|
|
1626
|
+
)
|
|
1627
|
+
return _json_response(
|
|
1628
|
+
200,
|
|
1629
|
+
_binding_result(context.request.request_id, binding),
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
async def _session_unarchive(self, context: _RequestContext) -> Response:
|
|
1633
|
+
payload = _parse_json(context.request)
|
|
1634
|
+
target = _action_target(payload)
|
|
1635
|
+
action_kind = _required_text(payload, "actionKind", maximum=128)
|
|
1636
|
+
if action_kind not in {"sessions.unarchive", "sessions.unarchive-current"}:
|
|
1637
|
+
raise AdminWebError(400, "invalid_action", "恢复动作无效。")
|
|
1638
|
+
_require_body_keys(
|
|
1639
|
+
payload,
|
|
1640
|
+
_ACTION_KEYS | {"actionKind"},
|
|
1641
|
+
)
|
|
1642
|
+
grant = self._redeem_parsed(
|
|
1643
|
+
context,
|
|
1644
|
+
payload,
|
|
1645
|
+
action_kind,
|
|
1646
|
+
target,
|
|
1647
|
+
expected_resource="binding",
|
|
1648
|
+
)
|
|
1649
|
+
operation = (
|
|
1650
|
+
self._management.restore_exact_binding_as_current(
|
|
1651
|
+
target=_binding_target(grant)
|
|
1652
|
+
)
|
|
1653
|
+
if action_kind == "sessions.unarchive-current"
|
|
1654
|
+
else self._management.restore_exact_binding(target=_binding_target(grant))
|
|
1655
|
+
)
|
|
1656
|
+
binding = await self._mutation(
|
|
1657
|
+
context,
|
|
1658
|
+
action_kind,
|
|
1659
|
+
grant.target.target_id,
|
|
1660
|
+
operation,
|
|
1661
|
+
)
|
|
1662
|
+
return _json_response(
|
|
1663
|
+
200,
|
|
1664
|
+
_binding_result(context.request.request_id, binding),
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
async def _session_delete_lazy(self, context: _RequestContext) -> Response:
|
|
1668
|
+
_payload, grant = self._redeem(
|
|
1669
|
+
context,
|
|
1670
|
+
"sessions.delete-lazy",
|
|
1671
|
+
expected_resource="binding",
|
|
1672
|
+
)
|
|
1673
|
+
binding = await self._mutation(
|
|
1674
|
+
context,
|
|
1675
|
+
"sessions.delete-lazy",
|
|
1676
|
+
grant.target.target_id,
|
|
1677
|
+
self._management.delete_exact_lazy_binding(
|
|
1678
|
+
target=_binding_target(grant)
|
|
1679
|
+
),
|
|
1680
|
+
)
|
|
1681
|
+
return _json_response(
|
|
1682
|
+
200,
|
|
1683
|
+
_binding_result(context.request.request_id, binding),
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
async def _session_delete_materialized(
|
|
1687
|
+
self,
|
|
1688
|
+
context: _RequestContext,
|
|
1689
|
+
) -> Response:
|
|
1690
|
+
_payload, grant = self._redeem(
|
|
1691
|
+
context,
|
|
1692
|
+
"sessions.delete-materialized",
|
|
1693
|
+
expected_resource="binding",
|
|
1694
|
+
)
|
|
1695
|
+
target = _lifecycle_binding_target(grant)
|
|
1696
|
+
expected_native_thread_id = _expected_value(
|
|
1697
|
+
_grant_preconditions(grant).native_thread_id,
|
|
1698
|
+
"native Thread",
|
|
1699
|
+
)
|
|
1700
|
+
binding = await self._mutation(
|
|
1701
|
+
context,
|
|
1702
|
+
"sessions.delete-materialized",
|
|
1703
|
+
target.binding_id,
|
|
1704
|
+
self._management.delete_exact_binding(
|
|
1705
|
+
target=target,
|
|
1706
|
+
expected_native_thread_id=expected_native_thread_id,
|
|
1707
|
+
),
|
|
1708
|
+
)
|
|
1709
|
+
return _json_response(
|
|
1710
|
+
200,
|
|
1711
|
+
_binding_result(context.request.request_id, binding),
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
async def _session_stop(self, context: _RequestContext) -> Response:
|
|
1715
|
+
_payload, grant = self._redeem(
|
|
1716
|
+
context,
|
|
1717
|
+
"sessions.stop",
|
|
1718
|
+
expected_resource="binding",
|
|
1719
|
+
)
|
|
1720
|
+
stopped = await self._mutation(
|
|
1721
|
+
context,
|
|
1722
|
+
"sessions.stop",
|
|
1723
|
+
grant.target.target_id,
|
|
1724
|
+
self._management.stop_exact_binding(
|
|
1725
|
+
target=_binding_target(grant),
|
|
1726
|
+
runtime_precondition=_runtime_precondition(grant),
|
|
1727
|
+
),
|
|
1728
|
+
)
|
|
1729
|
+
return _json_response(
|
|
1730
|
+
200,
|
|
1731
|
+
{
|
|
1732
|
+
"requestId": context.request.request_id,
|
|
1733
|
+
"bindingId": stopped.binding.id,
|
|
1734
|
+
"disposition": stopped.disposition.value,
|
|
1735
|
+
"message": _stop_disposition_message(stopped.disposition),
|
|
1736
|
+
},
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
async def _session_release(self, context: _RequestContext) -> Response:
|
|
1740
|
+
_payload, grant = self._redeem(
|
|
1741
|
+
context,
|
|
1742
|
+
"sessions.release",
|
|
1743
|
+
expected_resource="binding",
|
|
1744
|
+
)
|
|
1745
|
+
released = await self._mutation(
|
|
1746
|
+
context,
|
|
1747
|
+
"sessions.release",
|
|
1748
|
+
grant.target.target_id,
|
|
1749
|
+
self._management.release_exact_binding(
|
|
1750
|
+
target=_binding_target(grant),
|
|
1751
|
+
runtime_precondition=_runtime_precondition(grant),
|
|
1752
|
+
),
|
|
1753
|
+
)
|
|
1754
|
+
return _json_response(
|
|
1755
|
+
200,
|
|
1756
|
+
{
|
|
1757
|
+
"requestId": context.request.request_id,
|
|
1758
|
+
"bindingId": released.binding.id,
|
|
1759
|
+
"disposition": released.disposition.value,
|
|
1760
|
+
"message": _release_disposition_message(released.disposition),
|
|
1761
|
+
},
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
async def _side_close(self, context: _RequestContext) -> Response:
|
|
1765
|
+
_payload, grant = self._redeem(
|
|
1766
|
+
context,
|
|
1767
|
+
"side-topics.close",
|
|
1768
|
+
expected_resource="side",
|
|
1769
|
+
)
|
|
1770
|
+
preconditions = _grant_preconditions(grant)
|
|
1771
|
+
closed = await self._mutation(
|
|
1772
|
+
context,
|
|
1773
|
+
"side-topics.close",
|
|
1774
|
+
grant.target.target_id,
|
|
1775
|
+
self._management.close_side(
|
|
1776
|
+
target=CurrentSideTarget(
|
|
1777
|
+
side_id=grant.target.target_id,
|
|
1778
|
+
app_id=_expected_value(preconditions.side_app_id, "Side app"),
|
|
1779
|
+
chat_id=_expected_value(preconditions.side_chat_id, "Side chat"),
|
|
1780
|
+
topic_id=_expected_optional(
|
|
1781
|
+
preconditions.side_topic_id,
|
|
1782
|
+
"Side topic",
|
|
1783
|
+
),
|
|
1784
|
+
root_message_id=_expected_optional(
|
|
1785
|
+
preconditions.side_root_message_id,
|
|
1786
|
+
"Side root message",
|
|
1787
|
+
),
|
|
1788
|
+
)
|
|
1789
|
+
),
|
|
1790
|
+
)
|
|
1791
|
+
return _json_response(
|
|
1792
|
+
200,
|
|
1793
|
+
{
|
|
1794
|
+
"requestId": context.request.request_id,
|
|
1795
|
+
"sideId": closed.record.id,
|
|
1796
|
+
"state": closed.record.state.value,
|
|
1797
|
+
"alreadyTerminal": closed.outcome is None,
|
|
1798
|
+
"missingRuntimeSession": closed.missing_runtime_session,
|
|
1799
|
+
},
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
async def _validated_settings(self, value: object) -> BindingTurnSettings | None:
|
|
1803
|
+
if value is None:
|
|
1804
|
+
return None
|
|
1805
|
+
if not isinstance(value, dict):
|
|
1806
|
+
raise AdminWebError(400, "invalid_settings", "会话配置必须是对象或 null。")
|
|
1807
|
+
_require_body_keys(value, {"modelId", "effortId", "serviceTierId"})
|
|
1808
|
+
return await self._management.resolve_turn_settings(
|
|
1809
|
+
model_id=_required_text(value, "modelId", maximum=256),
|
|
1810
|
+
effort_id=_required_text(value, "effortId", maximum=128),
|
|
1811
|
+
service_tier_id=_required_text(value, "serviceTierId", maximum=128),
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
def _grant(
|
|
1815
|
+
self,
|
|
1816
|
+
context: _RequestContext,
|
|
1817
|
+
action_kind: str,
|
|
1818
|
+
target: AdminActionTarget,
|
|
1819
|
+
preconditions: AdminActionPreconditions,
|
|
1820
|
+
) -> dict[str, object]:
|
|
1821
|
+
try:
|
|
1822
|
+
issued = self._auth.issue_action(
|
|
1823
|
+
context.session_token,
|
|
1824
|
+
action_kind=action_kind,
|
|
1825
|
+
target=target,
|
|
1826
|
+
preconditions=preconditions,
|
|
1827
|
+
)
|
|
1828
|
+
except AuthCapacityExceeded:
|
|
1829
|
+
raise AdminWebError(
|
|
1830
|
+
503,
|
|
1831
|
+
"action_capacity",
|
|
1832
|
+
"操作凭据容量已满,请稍后刷新页面。",
|
|
1833
|
+
) from None
|
|
1834
|
+
return {
|
|
1835
|
+
"actionKind": action_kind,
|
|
1836
|
+
"csrfToken": issued.csrf_token,
|
|
1837
|
+
"actionToken": issued.action_token,
|
|
1838
|
+
"target": _action_target_json(target),
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
def _redeem(
|
|
1842
|
+
self,
|
|
1843
|
+
context: _RequestContext,
|
|
1844
|
+
action_kind: str,
|
|
1845
|
+
*,
|
|
1846
|
+
expected_resource: str,
|
|
1847
|
+
allowed_extra: set[str] | None = None,
|
|
1848
|
+
):
|
|
1849
|
+
payload = _parse_json(context.request)
|
|
1850
|
+
_require_body_keys(payload, _ACTION_KEYS | (allowed_extra or set()))
|
|
1851
|
+
target = _action_target(payload)
|
|
1852
|
+
grant = self._redeem_parsed(
|
|
1853
|
+
context,
|
|
1854
|
+
payload,
|
|
1855
|
+
action_kind,
|
|
1856
|
+
target,
|
|
1857
|
+
expected_resource=expected_resource,
|
|
1858
|
+
)
|
|
1859
|
+
return payload, grant
|
|
1860
|
+
|
|
1861
|
+
def _redeem_parsed(
|
|
1862
|
+
self,
|
|
1863
|
+
context: _RequestContext,
|
|
1864
|
+
payload: Mapping[str, object],
|
|
1865
|
+
action_kind: str,
|
|
1866
|
+
target: AdminActionTarget,
|
|
1867
|
+
*,
|
|
1868
|
+
expected_resource: str,
|
|
1869
|
+
):
|
|
1870
|
+
if target.resource != expected_resource:
|
|
1871
|
+
raise AdminWebError(400, "invalid_target", "操作目标类型无效。")
|
|
1872
|
+
try:
|
|
1873
|
+
return self._auth.redeem_action(
|
|
1874
|
+
context.session_token,
|
|
1875
|
+
csrf_token=payload.get("csrfToken"),
|
|
1876
|
+
action_token=payload.get("actionToken"),
|
|
1877
|
+
action_kind=action_kind,
|
|
1878
|
+
target=target,
|
|
1879
|
+
)
|
|
1880
|
+
except ActionCsrfRejected:
|
|
1881
|
+
raise AdminWebError(403, "csrf_rejected", "操作校验失败。") from None
|
|
1882
|
+
except MalformedActionGrant:
|
|
1883
|
+
raise AdminWebError(400, "invalid_action", "操作凭据无效。") from None
|
|
1884
|
+
except (StaleActionGrant, ConsumedActionGrant):
|
|
1885
|
+
raise AdminWebError(
|
|
1886
|
+
409,
|
|
1887
|
+
"stale_or_consumed",
|
|
1888
|
+
"操作已过期或已执行,请刷新页面后重试。",
|
|
1889
|
+
) from None
|
|
1890
|
+
except AdminAuthError:
|
|
1891
|
+
raise AdminWebError(401, "not_authenticated", "请重新登录。") from None
|
|
1892
|
+
|
|
1893
|
+
async def _mutation(
|
|
1894
|
+
self,
|
|
1895
|
+
context: _RequestContext,
|
|
1896
|
+
action_kind: str,
|
|
1897
|
+
target_id: str,
|
|
1898
|
+
operation: Awaitable[T],
|
|
1899
|
+
) -> T:
|
|
1900
|
+
async def execute() -> T:
|
|
1901
|
+
started = time.monotonic()
|
|
1902
|
+
terminal = "success"
|
|
1903
|
+
error_name: str | None = None
|
|
1904
|
+
try:
|
|
1905
|
+
result = await operation
|
|
1906
|
+
if isinstance(result, ProjectDeletionResult) and not result.deleted:
|
|
1907
|
+
terminal = "incomplete"
|
|
1908
|
+
error_name = result.code
|
|
1909
|
+
return result
|
|
1910
|
+
except BaseException as error:
|
|
1911
|
+
terminal = "cancelled" if isinstance(error, asyncio.CancelledError) else "error"
|
|
1912
|
+
error_name = type(error).__name__
|
|
1913
|
+
raise
|
|
1914
|
+
finally:
|
|
1915
|
+
event = {
|
|
1916
|
+
"event": "admin_mutation",
|
|
1917
|
+
"requestId": context.request.request_id,
|
|
1918
|
+
"session": context.session_log_handle,
|
|
1919
|
+
"action": action_kind,
|
|
1920
|
+
"targetId": target_id,
|
|
1921
|
+
"terminal": terminal,
|
|
1922
|
+
"elapsedMs": round((time.monotonic() - started) * 1000),
|
|
1923
|
+
"errorType": error_name,
|
|
1924
|
+
}
|
|
1925
|
+
logger.info(json.dumps(event, separators=(",", ":"), sort_keys=True))
|
|
1926
|
+
|
|
1927
|
+
task: asyncio.Task[T] = asyncio.create_task(
|
|
1928
|
+
execute(),
|
|
1929
|
+
name=f"netizen-admin:{action_kind}",
|
|
1930
|
+
)
|
|
1931
|
+
self._mutation_tasks.add(task) # type: ignore[arg-type]
|
|
1932
|
+
task.add_done_callback(self._mutation_finished)
|
|
1933
|
+
try:
|
|
1934
|
+
return await asyncio.shield(task)
|
|
1935
|
+
except asyncio.CancelledError:
|
|
1936
|
+
# The exact operation remains strongly tracked and is drained by
|
|
1937
|
+
# the Admin runner; a disconnected HTTP response is not rollback.
|
|
1938
|
+
raise
|
|
1939
|
+
|
|
1940
|
+
def _mutation_finished(self, task: asyncio.Task[object]) -> None:
|
|
1941
|
+
self._mutation_tasks.discard(task)
|
|
1942
|
+
if not task.cancelled():
|
|
1943
|
+
task.exception()
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
class AdminWebRunner:
|
|
1947
|
+
"""Bind, admit, and drain the Admin application on one event loop."""
|
|
1948
|
+
|
|
1949
|
+
def __init__(
|
|
1950
|
+
self,
|
|
1951
|
+
*,
|
|
1952
|
+
host: str,
|
|
1953
|
+
port: int | None,
|
|
1954
|
+
credential_path: Path,
|
|
1955
|
+
management: InstanceManagementService | None = None,
|
|
1956
|
+
auth: AdminAuth | None = None,
|
|
1957
|
+
instance_root: Path | None = None,
|
|
1958
|
+
config_path: Path | None = None,
|
|
1959
|
+
config_snapshot: ConfigFileSnapshot | None = None,
|
|
1960
|
+
access_host: str | None = None,
|
|
1961
|
+
) -> None:
|
|
1962
|
+
self._host = host
|
|
1963
|
+
self._port = port
|
|
1964
|
+
self._instance_root = instance_root
|
|
1965
|
+
self._config_path = config_path
|
|
1966
|
+
self._config_snapshot = config_snapshot
|
|
1967
|
+
self._access_host = validate_access_host(access_host) if access_host is not None else None
|
|
1968
|
+
self._urls: tuple[str, ...] = ()
|
|
1969
|
+
self._loopback_only = False
|
|
1970
|
+
self._bound = False
|
|
1971
|
+
self._auth = auth or AdminAuth(credential_path)
|
|
1972
|
+
self._application: AdminWebApplication | None = None
|
|
1973
|
+
self._authorities: tuple[str, ...] | None = None
|
|
1974
|
+
if management is not None:
|
|
1975
|
+
self.attach_management(management)
|
|
1976
|
+
self._transport = AdminHttpTransport(host, port if port is not None else 8787, self._handle)
|
|
1977
|
+
|
|
1978
|
+
@property
|
|
1979
|
+
def application(self) -> AdminWebApplication:
|
|
1980
|
+
application = self._application
|
|
1981
|
+
if application is None:
|
|
1982
|
+
raise RuntimeError("Admin management application is not attached")
|
|
1983
|
+
return application
|
|
1984
|
+
|
|
1985
|
+
@property
|
|
1986
|
+
def transport(self) -> AdminHttpTransport:
|
|
1987
|
+
return self._transport
|
|
1988
|
+
|
|
1989
|
+
@property
|
|
1990
|
+
def addresses(self):
|
|
1991
|
+
return self._transport.addresses
|
|
1992
|
+
|
|
1993
|
+
@property
|
|
1994
|
+
def instance_root(self) -> Path | None:
|
|
1995
|
+
return self._instance_root
|
|
1996
|
+
|
|
1997
|
+
@property
|
|
1998
|
+
def urls(self) -> tuple[str, ...]:
|
|
1999
|
+
"""Bound runtime addresses, never guesses from pending disk settings."""
|
|
2000
|
+
return self._urls
|
|
2001
|
+
|
|
2002
|
+
@property
|
|
2003
|
+
def loopback_only(self) -> bool:
|
|
2004
|
+
return self._loopback_only
|
|
2005
|
+
|
|
2006
|
+
async def bind(self) -> None:
|
|
2007
|
+
if self._bound:
|
|
2008
|
+
raise RuntimeError("Admin Web runner can only bind once")
|
|
2009
|
+
self._bound = True
|
|
2010
|
+
try:
|
|
2011
|
+
if self._port is None:
|
|
2012
|
+
snapshot = self._config_snapshot
|
|
2013
|
+
if self._config_path is None or snapshot is None or snapshot.path != self._config_path:
|
|
2014
|
+
raise ValueError("automatic Admin port allocation requires the startup configuration snapshot")
|
|
2015
|
+
for port in _AUTO_PORTS:
|
|
2016
|
+
self._transport = AdminHttpTransport(self._host, port, self._handle)
|
|
2017
|
+
try:
|
|
2018
|
+
await self._transport.bind()
|
|
2019
|
+
except OSError as error:
|
|
2020
|
+
if error.errno != errno.EADDRINUSE:
|
|
2021
|
+
raise
|
|
2022
|
+
else:
|
|
2023
|
+
persist_admin_port(snapshot, port)
|
|
2024
|
+
self._port = port
|
|
2025
|
+
break
|
|
2026
|
+
else:
|
|
2027
|
+
raise OSError(
|
|
2028
|
+
errno.EADDRINUSE,
|
|
2029
|
+
f"Admin ports 8787–8886 are occupied; configure adminWeb.port in {self._config_path}",
|
|
2030
|
+
)
|
|
2031
|
+
else:
|
|
2032
|
+
try:
|
|
2033
|
+
await self._transport.bind()
|
|
2034
|
+
except OSError as error:
|
|
2035
|
+
if error.errno == errno.EADDRINUSE and self._config_path is not None:
|
|
2036
|
+
raise OSError(
|
|
2037
|
+
errno.EADDRINUSE,
|
|
2038
|
+
f"Admin port {self._port} is occupied; configure adminWeb.port in {self._config_path}",
|
|
2039
|
+
) from error
|
|
2040
|
+
raise
|
|
2041
|
+
assert self._port is not None
|
|
2042
|
+
self._authorities = await asyncio.to_thread(
|
|
2043
|
+
accepted_authorities,
|
|
2044
|
+
self._host,
|
|
2045
|
+
self._port,
|
|
2046
|
+
self._transport.addresses,
|
|
2047
|
+
)
|
|
2048
|
+
if self._access_host is not None:
|
|
2049
|
+
self._authorities = tuple(sorted({
|
|
2050
|
+
*self._authorities,
|
|
2051
|
+
*(_format_authority(self._access_host, port) for port in _bound_ports(self.addresses)),
|
|
2052
|
+
}))
|
|
2053
|
+
self._loopback_only = _loopback_addresses(self.addresses)
|
|
2054
|
+
self._urls = admin_access_urls(
|
|
2055
|
+
self._host, self.addresses, self._authorities, self._access_host,
|
|
2056
|
+
)
|
|
2057
|
+
if self._application is not None:
|
|
2058
|
+
self._application.configure_authorities(self._authorities)
|
|
2059
|
+
except BaseException:
|
|
2060
|
+
try:
|
|
2061
|
+
# Failure before bind has no loop-owned transport to close.
|
|
2062
|
+
if self._transport.state is not AdminHttpState.NEW:
|
|
2063
|
+
await self._transport.close()
|
|
2064
|
+
finally:
|
|
2065
|
+
self._urls = ()
|
|
2066
|
+
self._auth.close()
|
|
2067
|
+
raise
|
|
2068
|
+
|
|
2069
|
+
def attach_management(self, management: InstanceManagementService) -> None:
|
|
2070
|
+
"""Attach the sole application service after the closed listener binds."""
|
|
2071
|
+
|
|
2072
|
+
if self._application is not None:
|
|
2073
|
+
raise RuntimeError("Admin management application is already attached")
|
|
2074
|
+
application = AdminWebApplication(
|
|
2075
|
+
auth=self._auth, management=management, instance_root=self._instance_root,
|
|
2076
|
+
)
|
|
2077
|
+
if self._authorities is not None:
|
|
2078
|
+
application.configure_authorities(self._authorities)
|
|
2079
|
+
self._application = application
|
|
2080
|
+
|
|
2081
|
+
async def _handle(self, request: Request) -> Response:
|
|
2082
|
+
application = self._application
|
|
2083
|
+
if application is None:
|
|
2084
|
+
return _secure(
|
|
2085
|
+
_json_error(
|
|
2086
|
+
request.request_id,
|
|
2087
|
+
503,
|
|
2088
|
+
"not_ready",
|
|
2089
|
+
"服务尚未就绪。",
|
|
2090
|
+
)
|
|
2091
|
+
)
|
|
2092
|
+
return await application.handle(request)
|
|
2093
|
+
|
|
2094
|
+
def open_admission(self) -> None:
|
|
2095
|
+
self.application.open_admission()
|
|
2096
|
+
self._transport.open_admission()
|
|
2097
|
+
|
|
2098
|
+
def close_admission(self) -> None:
|
|
2099
|
+
self._transport.close_admission()
|
|
2100
|
+
if self._application is not None:
|
|
2101
|
+
self._application.close_admission()
|
|
2102
|
+
|
|
2103
|
+
async def close_listener(self) -> None:
|
|
2104
|
+
self.close_admission()
|
|
2105
|
+
await self._transport.close()
|
|
2106
|
+
self._urls = ()
|
|
2107
|
+
|
|
2108
|
+
async def drain(self, deadline: float) -> None:
|
|
2109
|
+
await self._transport.drain(deadline)
|
|
2110
|
+
self._urls = ()
|
|
2111
|
+
if self._application is not None:
|
|
2112
|
+
await self._application.drain(deadline)
|
|
2113
|
+
|
|
2114
|
+
def close_auth(self) -> None:
|
|
2115
|
+
if self._application is not None:
|
|
2116
|
+
self._application.close_auth()
|
|
2117
|
+
else:
|
|
2118
|
+
self._auth.close()
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def accepted_authorities(
|
|
2122
|
+
bind_host: str,
|
|
2123
|
+
configured_port: int,
|
|
2124
|
+
addresses: Sequence[object],
|
|
2125
|
+
) -> tuple[str, ...]:
|
|
2126
|
+
"""Build the exact Host allowlist from local names and bound sockets."""
|
|
2127
|
+
|
|
2128
|
+
ports: set[int] = set()
|
|
2129
|
+
hosts: set[str] = {"localhost", "127.0.0.1", "::1"}
|
|
2130
|
+
for address in addresses:
|
|
2131
|
+
if isinstance(address, tuple) and len(address) >= 2:
|
|
2132
|
+
if isinstance(address[0], str):
|
|
2133
|
+
hosts.add(address[0].split("%", 1)[0])
|
|
2134
|
+
if isinstance(address[1], int):
|
|
2135
|
+
ports.add(address[1])
|
|
2136
|
+
if not ports:
|
|
2137
|
+
ports.add(configured_port)
|
|
2138
|
+
if bind_host not in {"0.0.0.0", "::", ""}:
|
|
2139
|
+
hosts.add(bind_host.split("%", 1)[0])
|
|
2140
|
+
for name in (socket.gethostname(), socket.getfqdn()):
|
|
2141
|
+
if name:
|
|
2142
|
+
hosts.add(name.rstrip(".").lower())
|
|
2143
|
+
try:
|
|
2144
|
+
infos = socket.getaddrinfo(
|
|
2145
|
+
socket.gethostname(),
|
|
2146
|
+
None,
|
|
2147
|
+
type=socket.SOCK_STREAM,
|
|
2148
|
+
)
|
|
2149
|
+
except OSError:
|
|
2150
|
+
infos = []
|
|
2151
|
+
for _family, _kind, _protocol, _canonical, sockaddr in infos:
|
|
2152
|
+
if isinstance(sockaddr, tuple) and sockaddr and isinstance(sockaddr[0], str):
|
|
2153
|
+
hosts.add(sockaddr[0].split("%", 1)[0])
|
|
2154
|
+
authorities = {
|
|
2155
|
+
_format_authority(host, port)
|
|
2156
|
+
for host in hosts
|
|
2157
|
+
for port in ports
|
|
2158
|
+
if host
|
|
2159
|
+
}
|
|
2160
|
+
return tuple(sorted(authorities))
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
def _format_authority(host: str, port: int) -> str:
|
|
2164
|
+
normalized = host.rstrip(".").lower()
|
|
2165
|
+
try:
|
|
2166
|
+
address = ipaddress.ip_address(normalized)
|
|
2167
|
+
except ValueError:
|
|
2168
|
+
rendered = normalized
|
|
2169
|
+
else:
|
|
2170
|
+
rendered = f"[{address.compressed}]" if address.version == 6 else address.compressed
|
|
2171
|
+
return f"{rendered}:{port}"
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def _bound_ports(addresses: Sequence[object]) -> tuple[int, ...]:
|
|
2175
|
+
return tuple(sorted({
|
|
2176
|
+
address[1] for address in addresses
|
|
2177
|
+
if isinstance(address, tuple) and len(address) >= 2 and isinstance(address[1], int)
|
|
2178
|
+
}))
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def _loopback_addresses(addresses: Sequence[object]) -> bool:
|
|
2182
|
+
hosts = [address[0] for address in addresses if isinstance(address, tuple) and address]
|
|
2183
|
+
return bool(hosts) and all(ipaddress.ip_address(host).is_loopback for host in hosts)
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
def admin_access_urls(
|
|
2187
|
+
bind_host: str,
|
|
2188
|
+
addresses: Sequence[object],
|
|
2189
|
+
authorities: Sequence[str],
|
|
2190
|
+
access_host: str | None = None,
|
|
2191
|
+
) -> tuple[str, ...]:
|
|
2192
|
+
"""A bounded set of URLs using the actual bound port and accepted Hosts."""
|
|
2193
|
+
|
|
2194
|
+
ports = _bound_ports(addresses)
|
|
2195
|
+
if not ports:
|
|
2196
|
+
return ()
|
|
2197
|
+
if access_host is not None:
|
|
2198
|
+
selected = [_format_authority(access_host, port) for port in ports]
|
|
2199
|
+
elif _loopback_addresses(addresses):
|
|
2200
|
+
selected = [
|
|
2201
|
+
_format_authority(str(address[0]), int(address[1]))
|
|
2202
|
+
for address in addresses if isinstance(address, tuple)
|
|
2203
|
+
]
|
|
2204
|
+
elif bind_host not in {"0.0.0.0", "::", ""}:
|
|
2205
|
+
selected = [_format_authority(bind_host, port) for port in ports]
|
|
2206
|
+
else:
|
|
2207
|
+
family_ports = {
|
|
2208
|
+
(ipaddress.ip_address(str(address[0])).version, int(address[1]))
|
|
2209
|
+
for address in addresses if isinstance(address, tuple) and len(address) >= 2
|
|
2210
|
+
}
|
|
2211
|
+
selected = []
|
|
2212
|
+
for authority in authorities:
|
|
2213
|
+
parsed = urlsplit(f"http://{authority}")
|
|
2214
|
+
hostname = parsed.hostname
|
|
2215
|
+
if not hostname or hostname == "localhost":
|
|
2216
|
+
continue
|
|
2217
|
+
if parsed.port not in ports:
|
|
2218
|
+
continue
|
|
2219
|
+
try:
|
|
2220
|
+
address = ipaddress.ip_address(hostname)
|
|
2221
|
+
except ValueError:
|
|
2222
|
+
# A discovered name may resolve only to an unbound address
|
|
2223
|
+
# family. Advertise the discovered IPs; accessHost remains
|
|
2224
|
+
# the explicit choice for a hostname.
|
|
2225
|
+
continue
|
|
2226
|
+
if (
|
|
2227
|
+
(address.version, parsed.port) not in family_ports
|
|
2228
|
+
or address.is_unspecified or address.is_loopback or address.is_link_local
|
|
2229
|
+
):
|
|
2230
|
+
continue
|
|
2231
|
+
selected.append(authority)
|
|
2232
|
+
if not selected:
|
|
2233
|
+
selected = [
|
|
2234
|
+
_format_authority("127.0.0.1" if family == 4 else "::1", port)
|
|
2235
|
+
for family, port in sorted(family_ports)
|
|
2236
|
+
]
|
|
2237
|
+
allowed = set(authorities)
|
|
2238
|
+
return tuple(f"http://{authority}/" for authority in dict.fromkeys(selected) if authority in allowed)[:4]
|
|
2239
|
+
|
|
2240
|
+
|
|
2241
|
+
def _load_assets() -> dict[str, bytes]:
|
|
2242
|
+
root = importlib.resources.files("netizen_cli.admin").joinpath("static")
|
|
2243
|
+
assets: dict[str, bytes] = {}
|
|
2244
|
+
for name in ("index.html", "admin.css", "admin.js"):
|
|
2245
|
+
assets[name] = root.joinpath(name).read_bytes()
|
|
2246
|
+
return assets
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def _root_label(root: Path | None) -> str:
|
|
2250
|
+
return str(root) if root is not None else "非受管开发实例"
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
def _login_html(nonce: str, instance_root: Path | None = None) -> bytes:
|
|
2254
|
+
escaped = html.escape(nonce, quote=True)
|
|
2255
|
+
return (
|
|
2256
|
+
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8'>"
|
|
2257
|
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
2258
|
+
"<title>Netizen Admin 登录</title>"
|
|
2259
|
+
"<link rel='stylesheet' href='/static/admin.css'></head>"
|
|
2260
|
+
"<body class='login-body'><main class='login-card'>"
|
|
2261
|
+
"<p class='eyebrow'>INSTANCE CONTROL PLANE</p>"
|
|
2262
|
+
"<h1>Netizen Admin</h1><p>输入实例管理员密钥。</p>"
|
|
2263
|
+
f"<p class='instance-root'>{html.escape(_root_label(instance_root))}</p>"
|
|
2264
|
+
"<form method='post' action='/login'>"
|
|
2265
|
+
f"<input type='hidden' name='nonce' value='{escaped}'>"
|
|
2266
|
+
"<label for='credential'>管理员密钥</label>"
|
|
2267
|
+
"<input id='credential' name='credential' type='password' "
|
|
2268
|
+
"autocomplete='current-password' required autofocus>"
|
|
2269
|
+
"<button type='submit'>登录</button></form></main></body></html>"
|
|
2270
|
+
).encode("utf-8")
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
def _secure(response: Response) -> Response:
|
|
2274
|
+
existing = {name.lower() for name, _value in response.headers}
|
|
2275
|
+
headers = list(response.headers)
|
|
2276
|
+
headers.extend(
|
|
2277
|
+
(name, value)
|
|
2278
|
+
for name, value in _SECURITY_HEADERS
|
|
2279
|
+
if name.lower() not in existing
|
|
2280
|
+
)
|
|
2281
|
+
return Response(response.status, tuple(headers), response.body)
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
def _json_response(status: int, payload: object) -> Response:
|
|
2285
|
+
body = json.dumps(
|
|
2286
|
+
payload,
|
|
2287
|
+
ensure_ascii=False,
|
|
2288
|
+
separators=(",", ":"),
|
|
2289
|
+
sort_keys=True,
|
|
2290
|
+
).encode("utf-8")
|
|
2291
|
+
return Response(
|
|
2292
|
+
status,
|
|
2293
|
+
((b"Content-Type", b"application/json; charset=utf-8"),),
|
|
2294
|
+
body,
|
|
2295
|
+
)
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
def _json_error(
|
|
2299
|
+
request_id: str,
|
|
2300
|
+
status: int,
|
|
2301
|
+
code: str,
|
|
2302
|
+
message: str,
|
|
2303
|
+
) -> Response:
|
|
2304
|
+
return _json_response(
|
|
2305
|
+
status,
|
|
2306
|
+
{"requestId": request_id, "code": code, "message": message},
|
|
2307
|
+
)
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
def _single_header(request: Request, name: bytes) -> bytes:
|
|
2311
|
+
values = request.header_values(name)
|
|
2312
|
+
if len(values) != 1 or not values[0]:
|
|
2313
|
+
raise AdminWebError(400, "invalid_headers", "请求头无效。")
|
|
2314
|
+
return values[0]
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def _optional_single_header(request: Request, name: bytes) -> bytes | None:
|
|
2318
|
+
values = request.header_values(name)
|
|
2319
|
+
if not values:
|
|
2320
|
+
return None
|
|
2321
|
+
if len(values) != 1:
|
|
2322
|
+
raise AdminWebError(400, "invalid_headers", "请求头无效。")
|
|
2323
|
+
return values[0]
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def _cookies(request: Request) -> dict[str, str]:
|
|
2327
|
+
raw = _optional_single_header(request, b"cookie")
|
|
2328
|
+
if raw is None:
|
|
2329
|
+
return {}
|
|
2330
|
+
try:
|
|
2331
|
+
text = raw.decode("ascii")
|
|
2332
|
+
parsed = SimpleCookie()
|
|
2333
|
+
parsed.load(text)
|
|
2334
|
+
except (UnicodeDecodeError, ValueError):
|
|
2335
|
+
raise AdminWebError(400, "invalid_cookie", "Cookie 无效。") from None
|
|
2336
|
+
return {name: morsel.value for name, morsel in parsed.items()}
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def _cookie(name: str, value: str) -> bytes:
|
|
2340
|
+
return (
|
|
2341
|
+
f"{name}={value}; Path=/; HttpOnly; SameSite=Strict"
|
|
2342
|
+
).encode("ascii")
|
|
2343
|
+
|
|
2344
|
+
|
|
2345
|
+
def _expired_cookie(name: str) -> bytes:
|
|
2346
|
+
return (
|
|
2347
|
+
f"{name}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"
|
|
2348
|
+
).encode("ascii")
|
|
2349
|
+
|
|
2350
|
+
|
|
2351
|
+
def _content_type(request: Request) -> str:
|
|
2352
|
+
raw = _single_header(request, b"content-type")
|
|
2353
|
+
try:
|
|
2354
|
+
return raw.decode("ascii").split(";", 1)[0].strip().lower()
|
|
2355
|
+
except UnicodeDecodeError:
|
|
2356
|
+
raise AdminWebError(415, "invalid_content_type", "请求格式不支持。") from None
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
def _parse_form(request: Request) -> Mapping[str, list[str]]:
|
|
2360
|
+
if _content_type(request) != _FORM_TYPE:
|
|
2361
|
+
raise AdminWebError(415, "invalid_content_type", "登录表单格式无效。")
|
|
2362
|
+
try:
|
|
2363
|
+
body = request.body.decode("utf-8")
|
|
2364
|
+
values = parse_qs(
|
|
2365
|
+
body,
|
|
2366
|
+
keep_blank_values=True,
|
|
2367
|
+
strict_parsing=True,
|
|
2368
|
+
max_num_fields=4,
|
|
2369
|
+
)
|
|
2370
|
+
except (UnicodeDecodeError, ValueError):
|
|
2371
|
+
raise AdminWebError(400, "invalid_form", "登录表单无效。") from None
|
|
2372
|
+
if set(values) != {"nonce", "credential"}:
|
|
2373
|
+
raise AdminWebError(400, "invalid_form", "登录表单无效。")
|
|
2374
|
+
return values
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
def _parse_json(request: Request) -> dict[str, object]:
|
|
2378
|
+
if _content_type(request) != _JSON_TYPE:
|
|
2379
|
+
raise AdminWebError(415, "invalid_content_type", "请求必须使用 JSON。")
|
|
2380
|
+
try:
|
|
2381
|
+
text = request.body.decode("utf-8")
|
|
2382
|
+
|
|
2383
|
+
def pairs(values: list[tuple[str, object]]) -> dict[str, object]:
|
|
2384
|
+
if len(values) > _MAX_JSON_FIELDS:
|
|
2385
|
+
raise ValueError("too many JSON fields")
|
|
2386
|
+
result: dict[str, object] = {}
|
|
2387
|
+
for key, value in values:
|
|
2388
|
+
if key in result:
|
|
2389
|
+
raise ValueError("duplicate JSON field")
|
|
2390
|
+
result[key] = value
|
|
2391
|
+
return result
|
|
2392
|
+
|
|
2393
|
+
value = json.loads(text, object_pairs_hook=pairs)
|
|
2394
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
2395
|
+
raise AdminWebError(400, "invalid_json", "请求 JSON 无效。") from None
|
|
2396
|
+
if not isinstance(value, dict):
|
|
2397
|
+
raise AdminWebError(400, "invalid_json", "请求 JSON 必须是对象。")
|
|
2398
|
+
return value
|
|
2399
|
+
|
|
2400
|
+
|
|
2401
|
+
def _one(values: Mapping[str, list[str]], name: str) -> str:
|
|
2402
|
+
items = values.get(name)
|
|
2403
|
+
if items is None or len(items) != 1 or not items[0]:
|
|
2404
|
+
raise AdminWebError(400, "invalid_input", f"缺少字段 {name}。")
|
|
2405
|
+
return items[0]
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def _batches(values: Sequence[str], size: int) -> tuple[tuple[str, ...], ...]:
|
|
2409
|
+
if size < 1:
|
|
2410
|
+
raise ValueError("batch size must be positive")
|
|
2411
|
+
return tuple(
|
|
2412
|
+
tuple(values[index : index + size])
|
|
2413
|
+
for index in range(0, len(values), size)
|
|
2414
|
+
)
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
def _required_text(
|
|
2418
|
+
payload: Mapping[str, object],
|
|
2419
|
+
name: str,
|
|
2420
|
+
*,
|
|
2421
|
+
maximum: int,
|
|
2422
|
+
) -> str:
|
|
2423
|
+
value = payload.get(name)
|
|
2424
|
+
if (
|
|
2425
|
+
not isinstance(value, str)
|
|
2426
|
+
or not value
|
|
2427
|
+
or value.strip() != value
|
|
2428
|
+
or len(value.encode("utf-8")) > maximum
|
|
2429
|
+
or any(ord(character) < 0x20 for character in value)
|
|
2430
|
+
):
|
|
2431
|
+
raise AdminWebError(400, "invalid_input", f"字段 {name} 无效。")
|
|
2432
|
+
return value
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
def _optional_text_body(
|
|
2436
|
+
payload: Mapping[str, object],
|
|
2437
|
+
name: str,
|
|
2438
|
+
*,
|
|
2439
|
+
maximum: int,
|
|
2440
|
+
) -> str | None:
|
|
2441
|
+
value = payload.get(name)
|
|
2442
|
+
if value is None:
|
|
2443
|
+
return None
|
|
2444
|
+
return _required_text(payload, name, maximum=maximum)
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
def _required_bool(payload: Mapping[str, object], name: str) -> bool:
|
|
2448
|
+
value = payload.get(name)
|
|
2449
|
+
if not isinstance(value, bool):
|
|
2450
|
+
raise AdminWebError(400, "invalid_input", f"字段 {name} 必须是布尔值。")
|
|
2451
|
+
return value
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def _required_int(
|
|
2455
|
+
payload: Mapping[str, object],
|
|
2456
|
+
name: str,
|
|
2457
|
+
*,
|
|
2458
|
+
minimum: int,
|
|
2459
|
+
) -> int:
|
|
2460
|
+
value = payload.get(name)
|
|
2461
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
2462
|
+
raise AdminWebError(400, "invalid_input", f"字段 {name} 必须是整数。")
|
|
2463
|
+
return value
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _require_body_keys(payload: Mapping[str, object], allowed: set[str]) -> None:
|
|
2467
|
+
if payload.keys() != allowed:
|
|
2468
|
+
missing = allowed - payload.keys()
|
|
2469
|
+
unknown = payload.keys() - allowed
|
|
2470
|
+
if missing or unknown:
|
|
2471
|
+
raise AdminWebError(400, "invalid_input", "请求字段不完整或包含未知字段。")
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
_ACTION_KEYS = {"csrfToken", "actionToken", "target"}
|
|
2475
|
+
|
|
2476
|
+
|
|
2477
|
+
def _require_schedule_success(result: dict[str, Any]) -> None:
|
|
2478
|
+
if result.get("ok") is False:
|
|
2479
|
+
error = result["error"]
|
|
2480
|
+
code = error["code"]
|
|
2481
|
+
status = {
|
|
2482
|
+
"revision_conflict": 409, "request_conflict": 409,
|
|
2483
|
+
"run_in_progress": 409, "blocked_unknown": 409,
|
|
2484
|
+
"project_disabled": 409, "project_unavailable": 409,
|
|
2485
|
+
"not_found": 404, "unavailable": 503,
|
|
2486
|
+
}.get(code, 400)
|
|
2487
|
+
raise AdminWebError(status, code, error["message"])
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
def _action_target(payload: Mapping[str, object]) -> AdminActionTarget:
|
|
2491
|
+
raw = payload.get("target")
|
|
2492
|
+
if not isinstance(raw, dict):
|
|
2493
|
+
raise AdminWebError(400, "invalid_target", "操作目标无效。")
|
|
2494
|
+
allowed = {"resource", "targetId", "scopeKey"}
|
|
2495
|
+
if not set(raw).issubset(allowed) or {"resource", "targetId"} - set(raw):
|
|
2496
|
+
raise AdminWebError(400, "invalid_target", "操作目标无效。")
|
|
2497
|
+
resource = _required_text(raw, "resource", maximum=64)
|
|
2498
|
+
target_id = _required_text(raw, "targetId", maximum=512)
|
|
2499
|
+
scope_key = _optional_text_body(raw, "scopeKey", maximum=1_024)
|
|
2500
|
+
return AdminActionTarget(resource, target_id, scope_key)
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def _action_target_json(
|
|
2504
|
+
target: AdminActionTarget,
|
|
2505
|
+
) -> dict[str, object]:
|
|
2506
|
+
return {
|
|
2507
|
+
"resource": target.resource,
|
|
2508
|
+
"targetId": target.target_id,
|
|
2509
|
+
"scopeKey": target.scope_key,
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
def _empty_preconditions(
|
|
2514
|
+
*,
|
|
2515
|
+
active_binding_id: ExpectedValue[str] | None = None,
|
|
2516
|
+
project_revision: ExpectedValue[int] | None = None,
|
|
2517
|
+
settings_revision: ExpectedValue[int] | None = None,
|
|
2518
|
+
native_thread_id: ExpectedValue[str] | None = None,
|
|
2519
|
+
activity_revision: ExpectedValue[int] | None = None,
|
|
2520
|
+
physical_turn_id: ExpectedValue[str] | None = None,
|
|
2521
|
+
side_app_id: ExpectedValue[str] | None = None,
|
|
2522
|
+
side_chat_id: ExpectedValue[str] | None = None,
|
|
2523
|
+
side_topic_id: ExpectedValue[str] | None = None,
|
|
2524
|
+
side_root_message_id: ExpectedValue[str] | None = None,
|
|
2525
|
+
inventory_fingerprint: ExpectedValue[str] | None = None,
|
|
2526
|
+
schedule_revision: ExpectedValue[int] | None = None,
|
|
2527
|
+
default_revision: ExpectedValue[int] | None = None,
|
|
2528
|
+
default_order_revision: ExpectedValue[int] | None = None,
|
|
2529
|
+
) -> AdminActionPreconditions:
|
|
2530
|
+
return AdminActionPreconditions(
|
|
2531
|
+
active_binding_id or ExpectedValue.dont_check(),
|
|
2532
|
+
project_revision or ExpectedValue.dont_check(),
|
|
2533
|
+
settings_revision or ExpectedValue.dont_check(),
|
|
2534
|
+
native_thread_id or ExpectedValue.dont_check(),
|
|
2535
|
+
activity_revision or ExpectedValue.dont_check(),
|
|
2536
|
+
physical_turn_id or ExpectedValue.dont_check(),
|
|
2537
|
+
side_app_id or ExpectedValue.dont_check(),
|
|
2538
|
+
side_chat_id or ExpectedValue.dont_check(),
|
|
2539
|
+
side_topic_id or ExpectedValue.dont_check(),
|
|
2540
|
+
side_root_message_id or ExpectedValue.dont_check(),
|
|
2541
|
+
inventory_fingerprint or ExpectedValue.dont_check(),
|
|
2542
|
+
schedule_revision or ExpectedValue.dont_check(),
|
|
2543
|
+
default_revision or ExpectedValue.dont_check(),
|
|
2544
|
+
default_order_revision or ExpectedValue.dont_check(),
|
|
2545
|
+
)
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
def _grant_preconditions(grant: object) -> AdminActionPreconditions:
|
|
2549
|
+
value = getattr(grant, "preconditions", None)
|
|
2550
|
+
if not isinstance(value, AdminActionPreconditions):
|
|
2551
|
+
raise RuntimeError("Admin action precondition type mismatch")
|
|
2552
|
+
return value
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
def _expected_value(expected: ExpectedValue[T], label: str) -> T:
|
|
2556
|
+
if expected.mode.value != "expect_value" or expected.value is None:
|
|
2557
|
+
raise RuntimeError(f"{label} action precondition is unavailable")
|
|
2558
|
+
return expected.value
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def _expected_optional(expected: ExpectedValue[T], label: str) -> T | None:
|
|
2562
|
+
if expected.mode.value == "expect_none":
|
|
2563
|
+
return None
|
|
2564
|
+
return _expected_value(expected, label)
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
def _binding_target(grant: object) -> ExactBindingTarget:
|
|
2568
|
+
target = getattr(grant, "target", None)
|
|
2569
|
+
if not isinstance(target, AdminActionTarget) or target.scope_key is None:
|
|
2570
|
+
raise RuntimeError("Binding action target type mismatch")
|
|
2571
|
+
preconditions = _grant_preconditions(grant)
|
|
2572
|
+
expected = preconditions.active_binding_id
|
|
2573
|
+
if expected.mode.value == "expect_none":
|
|
2574
|
+
active = None
|
|
2575
|
+
else:
|
|
2576
|
+
active = _expected_value(expected, "active Binding")
|
|
2577
|
+
return ExactBindingTarget(
|
|
2578
|
+
scope_key=target.scope_key,
|
|
2579
|
+
binding_id=target.target_id,
|
|
2580
|
+
expected_active_binding_id=active,
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
def _lifecycle_binding_target(grant: object) -> ExactBindingTarget:
|
|
2585
|
+
"""Resolve an exact Binding without coupling lifecycle to Scope activity."""
|
|
2586
|
+
|
|
2587
|
+
target = getattr(grant, "target", None)
|
|
2588
|
+
if not isinstance(target, AdminActionTarget) or target.scope_key is None:
|
|
2589
|
+
raise RuntimeError("Binding action target type mismatch")
|
|
2590
|
+
return ExactBindingTarget(
|
|
2591
|
+
scope_key=target.scope_key,
|
|
2592
|
+
binding_id=target.target_id,
|
|
2593
|
+
expected_active_binding_id=None,
|
|
2594
|
+
)
|
|
2595
|
+
|
|
2596
|
+
|
|
2597
|
+
def _runtime_precondition(grant: object) -> RuntimePrecondition:
|
|
2598
|
+
preconditions = _grant_preconditions(grant)
|
|
2599
|
+
activity_revision = _expected_value(
|
|
2600
|
+
preconditions.activity_revision,
|
|
2601
|
+
"runtime activity revision",
|
|
2602
|
+
)
|
|
2603
|
+
physical_turn_id = _expected_optional(
|
|
2604
|
+
preconditions.physical_turn_id,
|
|
2605
|
+
"physical Turn",
|
|
2606
|
+
)
|
|
2607
|
+
return RuntimePrecondition(activity_revision, physical_turn_id)
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
_UPDATE_HTTP_ERRORS = {
|
|
2611
|
+
"invalid_update_target": (400, "invalid_update_target", "升级目标无效。"),
|
|
2612
|
+
"update_unsupported": (409, "update_unsupported", "请在对应 Python 环境运行 netizen update。"),
|
|
2613
|
+
"restart_unsupported": (409, "restart_unsupported", "仅绑定当前 Python 环境的受管实例支持 Admin 重启。"),
|
|
2614
|
+
"update_target_changed": (409, "update_target_changed", "升级目标已变化,请重新检查更新。"),
|
|
2615
|
+
"update_busy": (409, "update_busy", "另一个安装或维护操作正在执行,请稍后查看结果。"),
|
|
2616
|
+
"update_state_unavailable": (
|
|
2617
|
+
503, "update_state_unavailable", "维护状态无法确认,请检查实例维护记录。",
|
|
2618
|
+
),
|
|
2619
|
+
"update_lock_unavailable": (
|
|
2620
|
+
503, "update_state_unavailable", "无法取得维护锁,请检查实例状态。",
|
|
2621
|
+
),
|
|
2622
|
+
"update_installation_changed": (
|
|
2623
|
+
409, "update_target_changed", "当前安装已变化,请重新连接。",
|
|
2624
|
+
),
|
|
2625
|
+
"update_already_submitted": (409, "update_busy", "维护操作已提交,请查看已有操作结果。"),
|
|
2626
|
+
"update_recovery_required": (
|
|
2627
|
+
409, "update_recovery_required", "上次维护结果未确认,请检查服务状态并使用 netizen restart 恢复。",
|
|
2628
|
+
),
|
|
2629
|
+
"update_cleanup_unavailable": (
|
|
2630
|
+
503, "update_cleanup_unavailable", "暂时无法清理上次维护任务,请稍后重试。",
|
|
2631
|
+
),
|
|
2632
|
+
"update_submission_unknown": (
|
|
2633
|
+
503, "update_state_unavailable", "维护操作提交结果无法确认,请刷新查看;不要重复提交。",
|
|
2634
|
+
),
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def _map_error(error: BaseException) -> AdminWebError | None:
|
|
2639
|
+
if isinstance(error, AdminWebError):
|
|
2640
|
+
return error
|
|
2641
|
+
if isinstance(error, ThreadOccupied):
|
|
2642
|
+
return AdminWebError(409, "thread_occupied", str(error))
|
|
2643
|
+
if isinstance(error, UpdateError):
|
|
2644
|
+
mapped = _UPDATE_HTTP_ERRORS.get(error.code)
|
|
2645
|
+
return AdminWebError(*mapped) if mapped is not None else None
|
|
2646
|
+
if isinstance(error, (ValueError, AmbiguousBinding)) and not isinstance(error, ProjectError):
|
|
2647
|
+
return AdminWebError(400, "invalid_input", "请求参数无效。")
|
|
2648
|
+
if isinstance(
|
|
2649
|
+
error,
|
|
2650
|
+
(BindingNotFound, ScopeNotFound, SideTopicNotFound, UnknownProject),
|
|
2651
|
+
):
|
|
2652
|
+
return AdminWebError(404, "not_found", "目标不存在。")
|
|
2653
|
+
if isinstance(
|
|
2654
|
+
error,
|
|
2655
|
+
(
|
|
2656
|
+
ActivePointerChanged,
|
|
2657
|
+
BindingScopeMismatch,
|
|
2658
|
+
BindingSettingsRevisionConflict,
|
|
2659
|
+
RuntimeStateChanged,
|
|
2660
|
+
StaleProject,
|
|
2661
|
+
StoredProjectConflict,
|
|
2662
|
+
ProjectAlreadyExists,
|
|
2663
|
+
ProjectDisabled,
|
|
2664
|
+
SideIdentityMismatch,
|
|
2665
|
+
SideSessionConflict,
|
|
2666
|
+
ThreadArchived,
|
|
2667
|
+
ThreadNotArchived,
|
|
2668
|
+
ThreadNotMaterialized,
|
|
2669
|
+
ThreadDeleteUnavailable,
|
|
2670
|
+
ThreadCompacting,
|
|
2671
|
+
ThreadGoalActive,
|
|
2672
|
+
ThreadRunningConfiguration,
|
|
2673
|
+
ThreadBackgroundTerminalsActive,
|
|
2674
|
+
NativeThreadMissing,
|
|
2675
|
+
),
|
|
2676
|
+
):
|
|
2677
|
+
return AdminWebError(409, "stale_or_conflict", "状态已变化,请刷新后重试。")
|
|
2678
|
+
if isinstance(error, (BindingQueryBusy, BlockingIOExecutorSaturated)):
|
|
2679
|
+
return AdminWebError(429, "busy", "管理服务繁忙,请稍后重试。")
|
|
2680
|
+
if isinstance(error, BindingQueryTimeout):
|
|
2681
|
+
return AdminWebError(504, "query_timeout", "查询超时,请缩小筛选范围。")
|
|
2682
|
+
if isinstance(error, ThreadCatalogDeadlineExceeded):
|
|
2683
|
+
return AdminWebError(504, "query_timeout", "原生会话查询超时。")
|
|
2684
|
+
if isinstance(error, TimeoutError):
|
|
2685
|
+
return AdminWebError(504, "deadline_exceeded", "操作等待超时,请刷新对账。")
|
|
2686
|
+
if isinstance(
|
|
2687
|
+
error,
|
|
2688
|
+
(
|
|
2689
|
+
AdmissionClosed,
|
|
2690
|
+
AuthCapacityExceeded,
|
|
2691
|
+
BindingQueryClosed,
|
|
2692
|
+
BlockingIOExecutorClosed,
|
|
2693
|
+
BlockingIOResultUnknown,
|
|
2694
|
+
BlockingIODrainTimeout,
|
|
2695
|
+
BlockingIOShutdownTimeout,
|
|
2696
|
+
NativeCatalogInconsistent,
|
|
2697
|
+
RuntimeClosed,
|
|
2698
|
+
ThreadCatalogError,
|
|
2699
|
+
ThreadLifecycleStateUnknown,
|
|
2700
|
+
ThreadReleaseStateUnknown,
|
|
2701
|
+
),
|
|
2702
|
+
):
|
|
2703
|
+
return AdminWebError(503, "state_unknown", "结果未确认,请刷新对账。")
|
|
2704
|
+
if isinstance(error, MalformedActionGrant):
|
|
2705
|
+
return AdminWebError(400, "invalid_action", "操作凭据无效。")
|
|
2706
|
+
if isinstance(
|
|
2707
|
+
error,
|
|
2708
|
+
(
|
|
2709
|
+
ProjectError,
|
|
2710
|
+
SideCloseFailed,
|
|
2711
|
+
SideSessionNotFound,
|
|
2712
|
+
ThreadLifecycleError,
|
|
2713
|
+
ThreadReleaseError,
|
|
2714
|
+
),
|
|
2715
|
+
):
|
|
2716
|
+
return AdminWebError(409, "operation_rejected", str(error)[:500])
|
|
2717
|
+
return None
|