netizen-cli 0.10.0__py3-none-any.whl

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