@agentsdock/server 1.0.7-beta.5

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,1354 @@
1
+ """Managed in-process Team Hub boundary for one designated AgentsServer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ipaddress
7
+ import logging
8
+ from pathlib import Path
9
+ import re
10
+ import threading
11
+ import time
12
+ from typing import Any, Callable
13
+ from urllib.parse import urlsplit
14
+
15
+ from fastapi import Request
16
+ from starlette.responses import JSONResponse
17
+
18
+ from agentsdock_team_hub.secure_peer import SecurePeerError
19
+ from agentsdock_team_hub.service import (
20
+ ManagedTransportIdentity,
21
+ classify_managed_transport,
22
+ create_app,
23
+ )
24
+ from agentsdock_team_hub.store import HubError, HubStore
25
+
26
+
27
+ TEAM_HUB_MOUNT_PATH = "/api/team-hub"
28
+ TEAM_HUB_SERVER_SESSION_MOUNT_PATH = "/api/team-hub-server"
29
+ TEAM_HUB_CAPABILITY_VERSION = 1
30
+ TEAM_HUB_MODE_DISABLED = "disabled"
31
+ TEAM_HUB_MODE_HOST = "host"
32
+ TEAM_HUB_TRANSPORT_LOOPBACK = "loopback"
33
+ TEAM_HUB_TRANSPORT_TAILSCALE_SERVE = "tailscale_serve"
34
+ TEAM_HUB_TRANSPORT_DIRECT_IP = "direct_ip"
35
+ TEAM_HUB_TAILSCALE_SERVE_PORT = 8444
36
+ TEAM_HUB_TAILNET_LABEL_PATTERN = re.compile(
37
+ r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$"
38
+ )
39
+ TEAM_HUB_STARTUP_FAILURE_REASON_MAX_CHARS = 320
40
+ TEAM_HUB_STARTUP_SECRET_RE = re.compile(
41
+ r"(?i)\b(token|secret|password|proof|authorization|bearer)\b"
42
+ r"(?:\s*[:=]\s*|\s+)[^\s,;]+"
43
+ )
44
+ TEAM_HUB_ABSOLUTE_PATH_RE = re.compile(
45
+ r"(?<![A-Za-z0-9])(?:/[A-Za-z0-9_.~+@%=-][^\s,;]*|[A-Za-z]:[\\/][^\s,;]*)"
46
+ )
47
+
48
+
49
+ def concise_team_hub_startup_failure(exc: BaseException) -> str:
50
+ """Return one bounded operator-safe reason without credentials or paths."""
51
+
52
+ message = " ".join(str(exc).split()).strip()
53
+ if not message:
54
+ message = type(exc).__name__
55
+ message = TEAM_HUB_STARTUP_SECRET_RE.sub(
56
+ lambda match: f"{match.group(1)}=[redacted]",
57
+ message,
58
+ )
59
+ message = TEAM_HUB_ABSOLUTE_PATH_RE.sub("[path]", message)
60
+ if len(message) > TEAM_HUB_STARTUP_FAILURE_REASON_MAX_CHARS:
61
+ message = (
62
+ message[: TEAM_HUB_STARTUP_FAILURE_REASON_MAX_CHARS - 3].rstrip()
63
+ + "..."
64
+ )
65
+ return message
66
+
67
+
68
+ def _canonical_tailnet_hostname(value: str) -> bool:
69
+ labels = value.split(".")
70
+ return (
71
+ value == value.lower()
72
+ and not value.endswith(".")
73
+ and len(labels) >= 4
74
+ and labels[-2:] == ["ts", "net"]
75
+ and all(
76
+ 1 <= len(label) <= 63
77
+ and not label.startswith("xn--")
78
+ and TEAM_HUB_TAILNET_LABEL_PATTERN.fullmatch(label) is not None
79
+ for label in labels
80
+ )
81
+ )
82
+
83
+
84
+ def configured_team_hub_mode(value: str | None) -> tuple[str, str | None]:
85
+ normalized = str(value or "disabled").strip().lower()
86
+ if normalized in {"", TEAM_HUB_MODE_DISABLED}:
87
+ return TEAM_HUB_MODE_DISABLED, None
88
+ if normalized == TEAM_HUB_MODE_HOST:
89
+ return TEAM_HUB_MODE_HOST, None
90
+ return TEAM_HUB_MODE_DISABLED, "AGENTSDOCK_TEAM_HUB_MODE must be disabled or host"
91
+
92
+
93
+ def configured_team_hub_hosts(
94
+ bind_address: str | None,
95
+ configured: str | None,
96
+ ) -> set[str]:
97
+ hosts = {"127.0.0.1", "localhost", "[::1]", "::1"}
98
+ bind = str(bind_address or "").strip().lower()
99
+ if bind and bind not in {"0.0.0.0", "::", "[::]", "localhost"}:
100
+ hosts.add(bind)
101
+ for value in str(configured or "").split(","):
102
+ host = value.strip().lower()
103
+ if host:
104
+ hosts.add(host)
105
+ return hosts
106
+
107
+
108
+ def configured_team_hub_endpoint(
109
+ mode: str,
110
+ configured_url: str | None,
111
+ configured_transport: str | None = None,
112
+ server_port: int = 7850,
113
+ ) -> tuple[str | None, str | None, str | None, str | None]:
114
+ """Return transport, public Hub URL, public hostname, and config error."""
115
+
116
+ if mode != TEAM_HUB_MODE_HOST:
117
+ return None, None, None, None
118
+ value = str(configured_url or "").strip()
119
+ selected = str(configured_transport or "").strip().lower()
120
+ if not value:
121
+ if selected not in {"", TEAM_HUB_TRANSPORT_LOOPBACK}:
122
+ return (
123
+ None,
124
+ None,
125
+ None,
126
+ "AGENTSDOCK_TEAM_HUB_URL is required for the selected Team Hub transport",
127
+ )
128
+ return TEAM_HUB_TRANSPORT_LOOPBACK, None, None, None
129
+ try:
130
+ parsed = urlsplit(value)
131
+ port = parsed.port
132
+ except ValueError:
133
+ return None, None, None, "AGENTSDOCK_TEAM_HUB_URL is invalid"
134
+ hostname = parsed.hostname
135
+ if selected == TEAM_HUB_TRANSPORT_DIRECT_IP:
136
+ try:
137
+ address = ipaddress.ip_address(str(hostname or ""))
138
+ except ValueError:
139
+ address = None
140
+ canonical_host = str(address) if address is not None else ""
141
+ canonical = (
142
+ f"http://{canonical_host}:{server_port}{TEAM_HUB_MOUNT_PATH}"
143
+ if canonical_host
144
+ else ""
145
+ )
146
+ if (
147
+ parsed.scheme != "http"
148
+ or address is None
149
+ or address.version != 4
150
+ or address.is_loopback
151
+ or not 1 <= int(str(address).split(".", 1)[0]) < 224
152
+ or parsed.username is not None
153
+ or parsed.password is not None
154
+ or port != server_port
155
+ or parsed.path != TEAM_HUB_MOUNT_PATH
156
+ or parsed.query
157
+ or parsed.fragment
158
+ or value != canonical
159
+ ):
160
+ return (
161
+ None,
162
+ None,
163
+ None,
164
+ "AGENTSDOCK_TEAM_HUB_URL must be the exact same-origin "
165
+ f"http://<literal-ip>:{server_port}{TEAM_HUB_MOUNT_PATH} URL "
166
+ "for direct_ip transport",
167
+ )
168
+ return TEAM_HUB_TRANSPORT_DIRECT_IP, canonical, canonical_host, None
169
+
170
+ if selected not in {"", TEAM_HUB_TRANSPORT_TAILSCALE_SERVE}:
171
+ return (
172
+ None,
173
+ None,
174
+ None,
175
+ "AGENTSDOCK_TEAM_HUB_TRANSPORT must be loopback, tailscale_serve, or direct_ip",
176
+ )
177
+ canonical = (
178
+ f"https://{hostname}:{TEAM_HUB_TAILSCALE_SERVE_PORT}{TEAM_HUB_MOUNT_PATH}"
179
+ if hostname is not None
180
+ else ""
181
+ )
182
+ if (
183
+ parsed.scheme != "https"
184
+ or hostname is None
185
+ or parsed.username is not None
186
+ or parsed.password is not None
187
+ or port != TEAM_HUB_TAILSCALE_SERVE_PORT
188
+ or parsed.path != TEAM_HUB_MOUNT_PATH
189
+ or parsed.query
190
+ or parsed.fragment
191
+ or not _canonical_tailnet_hostname(hostname)
192
+ or value != canonical
193
+ ):
194
+ return (
195
+ None,
196
+ None,
197
+ None,
198
+ "AGENTSDOCK_TEAM_HUB_URL must be the canonical private "
199
+ "https://<host>.ts.net:8444/api/team-hub URL",
200
+ )
201
+ return TEAM_HUB_TRANSPORT_TAILSCALE_SERVE, canonical, hostname, None
202
+
203
+
204
+ async def _join_task_despite_caller_cancellation(
205
+ task: asyncio.Task[Any],
206
+ ) -> Any:
207
+ """Join an owned side effect even when the caller keeps cancelling."""
208
+
209
+ while True:
210
+ try:
211
+ return await asyncio.shield(task)
212
+ except asyncio.CancelledError:
213
+ if task.done():
214
+ return task.result()
215
+
216
+
217
+ class ManagedTeamHubHost:
218
+ """An unavailable-first ASGI mount whose credential realm is Hub-only."""
219
+
220
+ def __init__(
221
+ self,
222
+ *,
223
+ mode: str,
224
+ data_dir: Path,
225
+ server_identity: str,
226
+ server_instance_id: str = "server-instance-local-preview",
227
+ managed_host_display_name: str = "Team Hub host",
228
+ allowed_hosts: set[str],
229
+ transport: str = TEAM_HUB_TRANSPORT_LOOPBACK,
230
+ hub_url: str | None = None,
231
+ routes: dict[str, str | None] | None = None,
232
+ secure_peer_manager: Any | None = None,
233
+ reactivation_hub_id: str | None = None,
234
+ reactivation_operation_id: str | None = None,
235
+ reactivation_snapshot: Path | None = None,
236
+ update_hub_id: str | None = None,
237
+ update_operation_id: str | None = None,
238
+ update_snapshot: Path | None = None,
239
+ config_error: str | None = None,
240
+ logger: logging.Logger | None = None,
241
+ ) -> None:
242
+ self.mode = mode
243
+ self.data_dir = Path(data_dir)
244
+ self.server_identity = str(server_identity)
245
+ self.server_instance_id = str(server_instance_id)
246
+ self.managed_host_display_name = str(managed_host_display_name)
247
+ self.allowed_hosts = set(allowed_hosts)
248
+ self.transport = transport
249
+ self.hub_url = hub_url
250
+ configured_routes = dict(routes or {transport: hub_url})
251
+ route_error: str | None = None
252
+ if (
253
+ transport not in {
254
+ TEAM_HUB_TRANSPORT_LOOPBACK,
255
+ TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
256
+ TEAM_HUB_TRANSPORT_DIRECT_IP,
257
+ }
258
+ or configured_routes.get(transport) != hub_url
259
+ or any(
260
+ route not in {
261
+ TEAM_HUB_TRANSPORT_LOOPBACK,
262
+ TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
263
+ TEAM_HUB_TRANSPORT_DIRECT_IP,
264
+ }
265
+ for route in configured_routes
266
+ )
267
+ ):
268
+ route_error = "Team Hub routes do not contain the exact primary transport"
269
+ self.routes = {
270
+ route: configured_routes[route]
271
+ for route in (
272
+ transport,
273
+ TEAM_HUB_TRANSPORT_LOOPBACK,
274
+ TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
275
+ TEAM_HUB_TRANSPORT_DIRECT_IP,
276
+ )
277
+ if route in configured_routes
278
+ }
279
+ self.secure_peer_manager = secure_peer_manager
280
+ self.reactivation_hub_id = reactivation_hub_id
281
+ self.reactivation_operation_id = reactivation_operation_id
282
+ self.reactivation_snapshot = reactivation_snapshot
283
+ self.update_hub_id = update_hub_id
284
+ self.update_operation_id = update_operation_id
285
+ self.update_snapshot = update_snapshot
286
+ self.config_error = config_error or route_error
287
+ self.logger = logger or logging.getLogger("agents-server.team-hub")
288
+ self._guard = threading.RLock()
289
+ self._admission = asyncio.Condition()
290
+ self._in_flight = 0
291
+ self._delegate: Any | None = None
292
+ self._store: HubStore | None = None
293
+ self._runtime_lease_fd: int | None = None
294
+ self._accepting = False
295
+ self._startup_failed = False
296
+ self._startup_failure_reason: str | None = None
297
+ self._fenced_finalize_in_progress = False
298
+ self._bootstrap_rate_guard = threading.Lock()
299
+ self._bootstrap_rate_buckets: dict[str, tuple[int, int]] = {}
300
+
301
+ @property
302
+ def designated_host(self) -> bool:
303
+ return self.mode == TEAM_HUB_MODE_HOST
304
+
305
+ @property
306
+ def store(self) -> HubStore | None:
307
+ with self._guard:
308
+ return self._store
309
+
310
+ def enable_live_host(
311
+ self,
312
+ *,
313
+ transport: str,
314
+ hub_url: str | None,
315
+ routes: dict[str, str | None],
316
+ allowed_hosts: set[str],
317
+ managed_host_display_name: str,
318
+ reactivation_hub_id: str | None = None,
319
+ reactivation_operation_id: str | None = None,
320
+ reactivation_snapshot: Path | None = None,
321
+ commit_configuration: Callable[[], Any] | None = None,
322
+ ) -> dict[str, Any]:
323
+ """Activate this process as its own managed Hub host without restart.
324
+
325
+ The caller owns durable configuration. This method changes only the
326
+ in-process boundary, and publishes the new mode atomically with a
327
+ successfully initialized delegate. A failed initialization restores
328
+ the exact disabled runtime projection so health never claims that an
329
+ unusable host was enabled.
330
+ """
331
+
332
+ if transport not in {
333
+ TEAM_HUB_TRANSPORT_LOOPBACK,
334
+ TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
335
+ TEAM_HUB_TRANSPORT_DIRECT_IP,
336
+ }:
337
+ raise ValueError("Team Hub transport is invalid")
338
+ if routes.get(transport) != hub_url:
339
+ raise ValueError("Team Hub routes do not contain the primary transport")
340
+ reactivation_values = (
341
+ reactivation_hub_id,
342
+ reactivation_operation_id,
343
+ reactivation_snapshot,
344
+ )
345
+ if any(value is not None for value in reactivation_values) and not all(
346
+ value is not None for value in reactivation_values
347
+ ):
348
+ raise ValueError("Team Hub reactivation authority is incomplete")
349
+
350
+ with self._guard:
351
+ if self.designated_host:
352
+ self.rename_live_host(managed_host_display_name)
353
+ capability = self.capability()
354
+ if capability.get("available") is not True:
355
+ raise RuntimeError("the designated Team Hub host is unavailable")
356
+ return capability
357
+ if (
358
+ self._delegate is not None
359
+ or self._store is not None
360
+ or self._runtime_lease_fd is not None
361
+ ):
362
+ raise RuntimeError("the disabled Team Hub runtime is not clean")
363
+
364
+ previous = {
365
+ "mode": self.mode,
366
+ "transport": self.transport,
367
+ "hub_url": self.hub_url,
368
+ "routes": dict(self.routes),
369
+ "allowed_hosts": set(self.allowed_hosts),
370
+ "config_error": self.config_error,
371
+ "managed_host_display_name": self.managed_host_display_name,
372
+ "reactivation_hub_id": self.reactivation_hub_id,
373
+ "reactivation_operation_id": self.reactivation_operation_id,
374
+ "reactivation_snapshot": self.reactivation_snapshot,
375
+ }
376
+ self.mode = TEAM_HUB_MODE_HOST
377
+ self.transport = transport
378
+ self.hub_url = hub_url
379
+ self.routes = dict(routes)
380
+ self.allowed_hosts = set(allowed_hosts)
381
+ self.managed_host_display_name = str(managed_host_display_name)
382
+ self.reactivation_hub_id = reactivation_hub_id
383
+ self.reactivation_operation_id = reactivation_operation_id
384
+ self.reactivation_snapshot = reactivation_snapshot
385
+ self.config_error = None
386
+ self._startup_failed = False
387
+ self._startup_failure_reason = None
388
+
389
+ self.initialize(hold_admission=True, hold_peer_attachment=True)
390
+ available = self._store is not None
391
+ failure_reason = self._startup_failure_reason
392
+ if not available:
393
+ self.mode = str(previous["mode"])
394
+ self.transport = str(previous["transport"])
395
+ self.hub_url = previous["hub_url"]
396
+ self.routes = dict(previous["routes"])
397
+ self.allowed_hosts = set(previous["allowed_hosts"])
398
+ self.config_error = previous["config_error"]
399
+ self.managed_host_display_name = str(
400
+ previous["managed_host_display_name"]
401
+ )
402
+ self.reactivation_hub_id = previous["reactivation_hub_id"]
403
+ self.reactivation_operation_id = previous[
404
+ "reactivation_operation_id"
405
+ ]
406
+ self.reactivation_snapshot = previous["reactivation_snapshot"]
407
+ self._delegate = None
408
+ self._store = None
409
+ self._accepting = False
410
+ self._startup_failed = False
411
+ self._startup_failure_reason = None
412
+ raise RuntimeError(
413
+ failure_reason or "Team Hub host activation failed"
414
+ )
415
+
416
+ try:
417
+ if commit_configuration is not None:
418
+ commit_configuration()
419
+ if reactivation_hub_id is not None:
420
+ assert reactivation_operation_id is not None
421
+ assert reactivation_snapshot is not None
422
+ if not self.clear_maintenance_sync(
423
+ "host-reactivation",
424
+ reactivation_operation_id,
425
+ reactivation_snapshot,
426
+ ):
427
+ raise RuntimeError(
428
+ "Team Hub reactivation fence disappeared before commit"
429
+ )
430
+ self._finalize_committed_fenced_start()
431
+ elif self._store is not None:
432
+ self._attach_host_store(self._store)
433
+ except BaseException:
434
+ lease = self._runtime_lease_fd
435
+ self._runtime_lease_fd = None
436
+ self.mode = str(previous["mode"])
437
+ self.transport = str(previous["transport"])
438
+ self.hub_url = previous["hub_url"]
439
+ self.routes = dict(previous["routes"])
440
+ self.allowed_hosts = set(previous["allowed_hosts"])
441
+ self.config_error = previous["config_error"]
442
+ self.managed_host_display_name = str(
443
+ previous["managed_host_display_name"]
444
+ )
445
+ self.reactivation_hub_id = previous["reactivation_hub_id"]
446
+ self.reactivation_operation_id = previous[
447
+ "reactivation_operation_id"
448
+ ]
449
+ self.reactivation_snapshot = previous["reactivation_snapshot"]
450
+ self._delegate = None
451
+ self._store = None
452
+ self._accepting = False
453
+ self._startup_failed = False
454
+ self._startup_failure_reason = None
455
+ HubStore.release_managed_runtime_lease(lease)
456
+ raise
457
+ self._accepting = True
458
+ return self.capability()
459
+
460
+ def rename_live_host(self, display_name: str) -> None:
461
+ """Rename the active managed node without changing host identity."""
462
+
463
+ label = str(display_name)
464
+ with self._guard:
465
+ store = self._store
466
+ previous = self.managed_host_display_name
467
+ if store is None:
468
+ if self.designated_host:
469
+ raise RuntimeError("the designated Team Hub host is unavailable")
470
+ self.managed_host_display_name = label
471
+ return
472
+ try:
473
+ store.rename_managed_host(label)
474
+ except BaseException:
475
+ self.managed_host_display_name = previous
476
+ raise
477
+ self.managed_host_display_name = label
478
+
479
+ async def disable_live_host(self) -> dict[str, Any]:
480
+ """Drain and demote this live host without stopping AgentsServer."""
481
+
482
+ with self._guard:
483
+ if not self.designated_host:
484
+ return self.capability()
485
+ store = self._store
486
+ if store is None:
487
+ with self._guard:
488
+ lease = self._runtime_lease_fd
489
+ self._runtime_lease_fd = None
490
+ self._delegate = None
491
+ self._accepting = False
492
+ self._startup_failed = False
493
+ self._startup_failure_reason = None
494
+ self.mode = TEAM_HUB_MODE_DISABLED
495
+ HubStore.release_managed_runtime_lease(lease)
496
+ return self.capability()
497
+
498
+ drain_task = asyncio.create_task(self._close_and_drain())
499
+ try:
500
+ await asyncio.shield(drain_task)
501
+ except asyncio.CancelledError as cancellation:
502
+ try:
503
+ await _join_task_despite_caller_cancellation(drain_task)
504
+ except BaseException:
505
+ await self.reopen_admission()
506
+ raise
507
+ # Draining alone is not the demotion commit point. If the caller
508
+ # goes away before peer detachment, restore the still-live Host's
509
+ # admission boundary instead of leaving it unavailable forever.
510
+ await self.reopen_admission()
511
+ raise cancellation
512
+ except BaseException:
513
+ await self.reopen_admission()
514
+ raise
515
+
516
+ if self.secure_peer_manager is not None:
517
+ detach_task = asyncio.create_task(
518
+ asyncio.to_thread(
519
+ self.secure_peer_manager.detach_host_hub,
520
+ hub_store=store,
521
+ )
522
+ )
523
+ try:
524
+ await asyncio.shield(detach_task)
525
+ except asyncio.CancelledError as cancellation:
526
+ try:
527
+ await _join_task_despite_caller_cancellation(detach_task)
528
+ except BaseException:
529
+ # A failed detach has not published the local demotion.
530
+ # Restore admission on the still-owned Host, failing
531
+ # closed if its peer surface cannot reopen.
532
+ await self.reopen_admission()
533
+ raise
534
+ # Detachment is the commit boundary. Finish the local state
535
+ # transition before propagating cancellation to the caller.
536
+ cancellation_to_raise: asyncio.CancelledError | None = cancellation
537
+ except BaseException:
538
+ await self.reopen_admission()
539
+ raise
540
+ else:
541
+ cancellation_to_raise = None
542
+ else:
543
+ cancellation_to_raise = None
544
+
545
+ with self._guard:
546
+ if self._store is not store:
547
+ raise RuntimeError("Team Hub host ownership changed during demotion")
548
+ lease = self._runtime_lease_fd
549
+ self._runtime_lease_fd = None
550
+ self._delegate = None
551
+ self._store = None
552
+ self._accepting = False
553
+ self._startup_failed = False
554
+ self._startup_failure_reason = None
555
+ self.mode = TEAM_HUB_MODE_DISABLED
556
+ HubStore.release_managed_runtime_lease(lease)
557
+ result = self.capability()
558
+ if cancellation_to_raise is not None:
559
+ raise cancellation_to_raise
560
+ return result
561
+
562
+ def _attach_host_store(self, store: HubStore) -> None:
563
+ if self.secure_peer_manager is None:
564
+ return
565
+ try:
566
+ self.secure_peer_manager.attach_host_hub(
567
+ hub_id=store.hub_id,
568
+ hub_data_dir=self.data_dir,
569
+ hub_store=store,
570
+ )
571
+ except Exception as exc:
572
+ self.logger.error(
573
+ "secure peer host attachment failed error_type=%s error_code=%s",
574
+ type(exc).__name__,
575
+ getattr(exc, "code", "secure_peer_host_recovery_failed"),
576
+ )
577
+ error_code = str(
578
+ getattr(exc, "code", "secure_peer_host_recovery_failed")
579
+ )
580
+ if (
581
+ isinstance(exc, SecurePeerError)
582
+ and error_code == "secure_peer_host_address_unavailable"
583
+ ):
584
+ self.secure_peer_manager.mark_host_unavailable(
585
+ str(exc),
586
+ error_code=error_code,
587
+ action="Configure hosting with a current local IPv4 address, or restore the previous address.",
588
+ )
589
+ return
590
+ self.secure_peer_manager.mark_host_unavailable(
591
+ (
592
+ "An existing secure peer connection could not be "
593
+ "reconciled safely."
594
+ if error_code == "peer_identity_conflict"
595
+ else "The secure peer host could not finish recovery."
596
+ ),
597
+ error_code=error_code,
598
+ action=(
599
+ "Review or remove the conflicting logical server "
600
+ "connection, then retry host recovery."
601
+ if error_code == "peer_identity_conflict"
602
+ else "Retry after the Team Hub database is available."
603
+ ),
604
+ )
605
+
606
+ def initialize(
607
+ self,
608
+ *,
609
+ hold_admission: bool = False,
610
+ hold_peer_attachment: bool = False,
611
+ ) -> None:
612
+ if not self.designated_host:
613
+ if self.config_error:
614
+ self.logger.error("Team Hub is disabled: %s", self.config_error)
615
+ return
616
+ if self.config_error:
617
+ failure_reason = concise_team_hub_startup_failure(
618
+ RuntimeError(self.config_error)
619
+ )
620
+ with self._guard:
621
+ self._startup_failed = True
622
+ self._startup_failure_reason = failure_reason
623
+ self._accepting = False
624
+ self.logger.error(
625
+ "Team Hub host configuration is invalid reason=%s",
626
+ failure_reason,
627
+ )
628
+ return
629
+ lease: int | None = None
630
+ try:
631
+ lease = HubStore.acquire_managed_runtime_lease(self.data_dir)
632
+ application = create_app(
633
+ self.data_dir,
634
+ allowed_hosts=self.allowed_hosts,
635
+ managed_host_identity=self.server_identity,
636
+ managed_server_instance_id=self.server_instance_id,
637
+ managed_host_display_name=self.managed_host_display_name,
638
+ managed_transport=self.transport,
639
+ managed_hub_url=(
640
+ self.hub_url
641
+ or f"http://127.0.0.1{TEAM_HUB_MOUNT_PATH}"
642
+ ),
643
+ managed_routes={
644
+ route: (
645
+ url
646
+ or f"http://127.0.0.1{TEAM_HUB_MOUNT_PATH}"
647
+ )
648
+ for route, url in self.routes.items()
649
+ },
650
+ secure_peer_manager=self.secure_peer_manager,
651
+ managed_reactivation_hub_id=self.reactivation_hub_id,
652
+ managed_reactivation_operation_id=self.reactivation_operation_id,
653
+ managed_reactivation_snapshot=self.reactivation_snapshot,
654
+ managed_update_hub_id=self.update_hub_id,
655
+ managed_update_operation_id=self.update_operation_id,
656
+ managed_update_snapshot=self.update_snapshot,
657
+ )
658
+ except Exception as exc:
659
+ HubStore.release_managed_runtime_lease(lease)
660
+ failure_reason = concise_team_hub_startup_failure(exc)
661
+ with self._guard:
662
+ self._startup_failed = True
663
+ self._startup_failure_reason = failure_reason
664
+ self._delegate = None
665
+ self._store = None
666
+ self._accepting = False
667
+ self.logger.error(
668
+ "managed Team Hub activation failed error_type=%s reason=%s",
669
+ type(exc).__name__,
670
+ failure_reason,
671
+ )
672
+ return
673
+ with self._guard:
674
+ self._runtime_lease_fd = lease
675
+ self._delegate = application
676
+ self._store = application.state.store
677
+ self._accepting = not hold_admission
678
+ self._startup_failed = False
679
+ self._startup_failure_reason = None
680
+ if (
681
+ self.secure_peer_manager is not None
682
+ and not application.state.store.maintenance_fenced_start
683
+ and not hold_peer_attachment
684
+ ):
685
+ self._attach_host_store(application.state.store)
686
+
687
+ def _finalize_committed_fenced_start(self) -> None:
688
+ """Attach peer hosting after the installer consumes the exact fence.
689
+
690
+ The candidate remains manager-owned and running throughout commit.
691
+ Health polling drives this idempotent transition, so no service
692
+ bootout/restart window exists after rollback becomes impossible.
693
+ """
694
+
695
+ with self._guard:
696
+ store = self._store
697
+ if (
698
+ store is None
699
+ or not store.maintenance_fenced_start
700
+ or self._fenced_finalize_in_progress
701
+ ):
702
+ return
703
+ try:
704
+ fence = store.maintenance_fence()
705
+ except Exception as exc:
706
+ self.logger.error(
707
+ "Team Hub fenced finalization check failed error_type=%s",
708
+ type(exc).__name__,
709
+ )
710
+ return
711
+ if fence is not None:
712
+ return
713
+ self._fenced_finalize_in_progress = True
714
+ try:
715
+ if self.secure_peer_manager is not None:
716
+ self.secure_peer_manager.attach_host_hub(
717
+ hub_id=store.hub_id,
718
+ hub_data_dir=self.data_dir,
719
+ hub_store=store,
720
+ )
721
+ except Exception as exc:
722
+ self.logger.error(
723
+ "secure peer host post-commit attachment failed error_type=%s error_code=%s",
724
+ type(exc).__name__,
725
+ getattr(exc, "code", "secure_peer_host_recovery_failed"),
726
+ )
727
+ if self.secure_peer_manager is not None:
728
+ if (
729
+ isinstance(exc, SecurePeerError)
730
+ and exc.code == "secure_peer_host_address_unavailable"
731
+ ):
732
+ self.secure_peer_manager.mark_host_unavailable(
733
+ str(exc),
734
+ error_code=exc.code,
735
+ action="Configure hosting with a current local IPv4 address, or restore the previous address.",
736
+ )
737
+ return
738
+ self.secure_peer_manager.mark_host_unavailable(
739
+ "The secure peer host could not finish recovery.",
740
+ error_code=str(
741
+ getattr(
742
+ exc,
743
+ "code",
744
+ "secure_peer_host_recovery_failed",
745
+ )
746
+ ),
747
+ action="Retry after the Team Hub database is available.",
748
+ )
749
+ else:
750
+ with self._guard:
751
+ if self._store is store:
752
+ store.maintenance_fenced_start = False
753
+ store.reactivation_fenced_start = False
754
+ store.maintenance_fence_reason = None
755
+ store.maintenance_operation_id = None
756
+ store.maintenance_fence_snapshot = None
757
+ self.reactivation_hub_id = None
758
+ self.reactivation_operation_id = None
759
+ self.reactivation_snapshot = None
760
+ self.update_hub_id = None
761
+ self.update_operation_id = None
762
+ self.update_snapshot = None
763
+ finally:
764
+ with self._guard:
765
+ self._fenced_finalize_in_progress = False
766
+
767
+ def capability(self) -> dict[str, Any]:
768
+ self._finalize_committed_fenced_start()
769
+ with self._guard:
770
+ available = bool(self._accepting and self._store is not None)
771
+ hub_id = self._store.hub_id if available and self._store is not None else None
772
+ startup_failed = self._startup_failed
773
+ startup_failure_reason = self._startup_failure_reason
774
+ if not self.designated_host:
775
+ message = (
776
+ "Team Hub host mode is misconfigured on this AgentsServer."
777
+ if self.config_error
778
+ else "This AgentsServer is not the designated Team Hub host."
779
+ )
780
+ return {
781
+ "available": False,
782
+ "designated_host": False,
783
+ "version": TEAM_HUB_CAPABILITY_VERSION,
784
+ "base_path": None,
785
+ "transport": None,
786
+ "hub_url": None,
787
+ "routes": [],
788
+ "hub_id": None,
789
+ "host_server_identity": None,
790
+ "message": message,
791
+ "action": (
792
+ "Set AGENTSDOCK_TEAM_HUB_MODE to disabled or host."
793
+ if self.config_error
794
+ else "Configure AGENTSDOCK_TEAM_HUB_MODE=host on exactly one AgentsServer."
795
+ ),
796
+ }
797
+ return {
798
+ "available": available,
799
+ "designated_host": True,
800
+ "version": TEAM_HUB_CAPABILITY_VERSION,
801
+ "base_path": TEAM_HUB_MOUNT_PATH,
802
+ "transport": self.transport,
803
+ "hub_url": self.hub_url,
804
+ "routes": [
805
+ {"transport": route, "hub_url": url}
806
+ for route, url in self.routes.items()
807
+ ],
808
+ "hub_id": hub_id,
809
+ "host_server_identity": self.server_identity,
810
+ "startup_failure_reason": (
811
+ startup_failure_reason if startup_failed else None
812
+ ),
813
+ "message": (
814
+ "This AgentsServer hosts Team Hub over private Tailscale Serve."
815
+ if available and self.transport == TEAM_HUB_TRANSPORT_TAILSCALE_SERVE
816
+ else (
817
+ "This AgentsServer hosts Team Hub over direct, unencrypted IP access."
818
+ )
819
+ if available and self.transport == TEAM_HUB_TRANSPORT_DIRECT_IP
820
+ else "This AgentsServer hosts the local Team Hub preview."
821
+ if available
822
+ else "The designated Team Hub is unavailable."
823
+ ),
824
+ "action": (
825
+ None
826
+ if available
827
+ else "Restart the designated host after checking its local Team Hub logs."
828
+ if startup_failed
829
+ else "Wait for the designated Team Hub to finish starting."
830
+ ),
831
+ }
832
+
833
+ def server_session_available(self) -> bool:
834
+ """Return whether this exact managed host can expose its shared session."""
835
+
836
+ with self._guard:
837
+ store = self._store if self._accepting else None
838
+ if store is None or not self.server_identity:
839
+ return False
840
+ try:
841
+ if not bool(store.health().get("bootstrapped")):
842
+ return False
843
+ store.managed_server_claims()
844
+ return True
845
+ except Exception:
846
+ return False
847
+
848
+ def tailscale_serve_identity(
849
+ self,
850
+ request: Request,
851
+ ) -> ManagedTransportIdentity | None:
852
+ route_url = self.routes.get(TEAM_HUB_TRANSPORT_TAILSCALE_SERVE)
853
+ if route_url is None:
854
+ return None
855
+ identity = classify_managed_transport(
856
+ request,
857
+ managed_transport=TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
858
+ managed_hub_url=route_url,
859
+ )
860
+ if identity is None or identity.kind != TEAM_HUB_TRANSPORT_TAILSCALE_SERVE:
861
+ return None
862
+ return identity
863
+
864
+ def direct_ip_identity(
865
+ self,
866
+ request: Request,
867
+ ) -> ManagedTransportIdentity | None:
868
+ route_url = self.routes.get(TEAM_HUB_TRANSPORT_DIRECT_IP)
869
+ if route_url is None:
870
+ return None
871
+ identity = classify_managed_transport(
872
+ request,
873
+ managed_transport=TEAM_HUB_TRANSPORT_DIRECT_IP,
874
+ managed_hub_url=route_url,
875
+ )
876
+ if identity is None or identity.kind != TEAM_HUB_TRANSPORT_DIRECT_IP:
877
+ return None
878
+ return identity
879
+
880
+ async def issue_tailnet_bootstrap_proof(
881
+ self,
882
+ *,
883
+ request_id: str,
884
+ server_identity: str,
885
+ server_instance_id: str,
886
+ hub_url: str,
887
+ tailnet_login: str,
888
+ recipient_email: str,
889
+ display_name: str,
890
+ device_label: str,
891
+ transport: str = TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
892
+ ) -> dict[str, Any]:
893
+ """Admit one route-bound remote proof mutation into the Hub drain boundary."""
894
+
895
+ if transport not in {
896
+ TEAM_HUB_TRANSPORT_TAILSCALE_SERVE,
897
+ TEAM_HUB_TRANSPORT_DIRECT_IP,
898
+ } or transport not in self.routes:
899
+ raise HubError("bootstrap_unavailable", "Bootstrap is unavailable", 403)
900
+
901
+ async with self._admission:
902
+ with self._guard:
903
+ store = self._store if self._accepting else None
904
+ if store is not None:
905
+ self._in_flight += 1
906
+ if store is None:
907
+ raise HubError("hub_unavailable", "Team Hub is unavailable", 503)
908
+
909
+ window = int(time.monotonic() // 60)
910
+ normalized_login = str(tailnet_login).strip().lower()
911
+ with self._bootstrap_rate_guard:
912
+ allowed = True
913
+ for key, limit in ((normalized_login, 8), ("*", 200)):
914
+ prior_window, count = self._bootstrap_rate_buckets.get(
915
+ key, (window, 0)
916
+ )
917
+ if prior_window != window:
918
+ count = 0
919
+ count += 1
920
+ self._bootstrap_rate_buckets[key] = (window, count)
921
+ allowed = allowed and count <= limit
922
+ if len(self._bootstrap_rate_buckets) > 4096:
923
+ self._bootstrap_rate_buckets = {
924
+ key: value
925
+ for key, value in self._bootstrap_rate_buckets.items()
926
+ if value[0] >= window - 1
927
+ }
928
+ while len(self._bootstrap_rate_buckets) > 4096:
929
+ self._bootstrap_rate_buckets.pop(
930
+ next(iter(self._bootstrap_rate_buckets))
931
+ )
932
+ if not allowed:
933
+ async with self._admission:
934
+ self._in_flight = max(0, self._in_flight - 1)
935
+ if self._in_flight == 0:
936
+ self._admission.notify_all()
937
+ raise HubError("rate_limited", "Too many bootstrap proof requests", 429)
938
+
939
+ def issue() -> dict[str, Any]:
940
+ with HubStore.maintenance_control_lock(self.data_dir):
941
+ if store.maintenance_fence() is not None:
942
+ raise HubError(
943
+ "hub_maintenance",
944
+ "Team Hub is unavailable during server maintenance",
945
+ 503,
946
+ )
947
+ return store.issue_tailnet_bootstrap_proof(
948
+ request_id=request_id,
949
+ server_identity=server_identity,
950
+ server_instance_id=server_instance_id,
951
+ hub_url=hub_url,
952
+ tailnet_login=tailnet_login,
953
+ recipient_email=recipient_email,
954
+ display_name=display_name,
955
+ device_label=device_label,
956
+ transport=transport,
957
+ )
958
+
959
+ try:
960
+ return await asyncio.to_thread(issue)
961
+ finally:
962
+ async with self._admission:
963
+ self._in_flight = max(0, self._in_flight - 1)
964
+ if self._in_flight == 0:
965
+ self._admission.notify_all()
966
+
967
+ async def _close_and_drain(self) -> None:
968
+ if self.secure_peer_manager is not None:
969
+ # The TLS gateway bypasses ASGI but shares the authoritative Hub
970
+ # database. Close and drain it before a maintenance snapshot can
971
+ # begin so an acknowledged peer write cannot land post-snapshot.
972
+ close_task = asyncio.create_task(
973
+ asyncio.to_thread(
974
+ self.secure_peer_manager.close_host_admission
975
+ )
976
+ )
977
+ try:
978
+ await asyncio.shield(close_task)
979
+ except asyncio.CancelledError as cancellation:
980
+ # The native peer drain cannot be stopped once its worker has
981
+ # begun. Keep ownership until it settles so a later close
982
+ # cannot race the cancellation rollback's reopen.
983
+ try:
984
+ await _join_task_despite_caller_cancellation(close_task)
985
+ except BaseException as exc:
986
+ self.logger.error(
987
+ "secure peer maintenance drain failed during cancellation "
988
+ "error_type=%s",
989
+ type(exc).__name__,
990
+ )
991
+ raise cancellation
992
+ async with self._admission:
993
+ with self._guard:
994
+ self._accepting = False
995
+ while self._in_flight:
996
+ await self._admission.wait()
997
+
998
+ async def prepare_maintenance(
999
+ self,
1000
+ reason: str,
1001
+ *,
1002
+ drain_timeout_seconds: float = 10.0,
1003
+ persistent_fence: bool = True,
1004
+ operation_id: str | None = None,
1005
+ allow_unavailable_host: bool = False,
1006
+ _reopen_on_abort: bool = True,
1007
+ ) -> Path | None:
1008
+ """Close Hub admission, drain delegated requests, then snapshot."""
1009
+
1010
+ if not self.designated_host:
1011
+ return None
1012
+
1013
+ async def cleanup_aborted_maintenance(
1014
+ *,
1015
+ store: HubStore | None = None,
1016
+ snapshot: Path | None = None,
1017
+ ) -> None:
1018
+ if not _reopen_on_abort:
1019
+ # Lifespan shutdown may outlive its bounded caller while a
1020
+ # native snapshot worker settles. Publish permanent local
1021
+ # closure before any further await so that straggler can never
1022
+ # resurrect admission after the peer runtime is shut down.
1023
+ with self._guard:
1024
+ self._accepting = False
1025
+ if persistent_fence and store is not None:
1026
+ if snapshot is not None:
1027
+ await asyncio.to_thread(
1028
+ store.clear_maintenance_fence,
1029
+ expected_reason=reason,
1030
+ expected_operation_id=str(operation_id),
1031
+ expected_snapshot=snapshot,
1032
+ )
1033
+ # A failed snapshot worker may have installed an incomplete,
1034
+ # changed, or otherwise ambiguous fence without returning its
1035
+ # snapshot. Probe before reopening; a valid remaining marker
1036
+ # or a validation error must leave both admission surfaces
1037
+ # closed for explicit recovery.
1038
+ remaining_fence = await asyncio.to_thread(
1039
+ store.maintenance_fence
1040
+ )
1041
+ if remaining_fence is not None:
1042
+ raise RuntimeError(
1043
+ "Team Hub maintenance fence remains after abort"
1044
+ )
1045
+ if _reopen_on_abort:
1046
+ # Local ASGI and secure-peer admission form one boundary. They
1047
+ # reopen only after the exact durable fence is cleared (or
1048
+ # after a pre-snapshot abort where no fence was installed).
1049
+ await self.reopen_admission()
1050
+
1051
+ async def settle_aborted_maintenance(
1052
+ *,
1053
+ store: HubStore | None = None,
1054
+ snapshot: Path | None = None,
1055
+ ) -> asyncio.CancelledError | None:
1056
+ cleanup_task = asyncio.create_task(
1057
+ cleanup_aborted_maintenance(store=store, snapshot=snapshot)
1058
+ )
1059
+ try:
1060
+ await asyncio.shield(cleanup_task)
1061
+ return None
1062
+ except asyncio.CancelledError as cancellation:
1063
+ try:
1064
+ await _join_task_despite_caller_cancellation(cleanup_task)
1065
+ except BaseException as exc:
1066
+ self.logger.error(
1067
+ "Team Hub maintenance cancellation cleanup failed "
1068
+ "error_type=%s",
1069
+ type(exc).__name__,
1070
+ )
1071
+ return cancellation
1072
+ except BaseException as exc:
1073
+ self.logger.error(
1074
+ "Team Hub maintenance cancellation cleanup failed "
1075
+ "error_type=%s",
1076
+ type(exc).__name__,
1077
+ )
1078
+ return None
1079
+
1080
+ drain_task = asyncio.create_task(self._close_and_drain())
1081
+ try:
1082
+ await asyncio.wait_for(
1083
+ asyncio.shield(drain_task),
1084
+ timeout=max(0.1, float(drain_timeout_seconds)),
1085
+ )
1086
+ except asyncio.CancelledError as cancellation:
1087
+ drain_task.cancel()
1088
+ try:
1089
+ await _join_task_despite_caller_cancellation(drain_task)
1090
+ except BaseException:
1091
+ pass
1092
+ await settle_aborted_maintenance()
1093
+ raise cancellation
1094
+ except asyncio.TimeoutError as exc:
1095
+ drain_task.cancel()
1096
+ try:
1097
+ await _join_task_despite_caller_cancellation(drain_task)
1098
+ except BaseException:
1099
+ pass
1100
+ cleanup_cancellation = await settle_aborted_maintenance()
1101
+ if cleanup_cancellation is not None:
1102
+ raise cleanup_cancellation
1103
+ raise RuntimeError("Team Hub request drain timed out") from exc
1104
+ except BaseException:
1105
+ cleanup_cancellation = await settle_aborted_maintenance()
1106
+ if cleanup_cancellation is not None:
1107
+ raise cleanup_cancellation
1108
+ raise
1109
+ with self._guard:
1110
+ store = self._store
1111
+ startup_failed = self._startup_failed
1112
+ startup_failure_reason = self._startup_failure_reason
1113
+ if store is None:
1114
+ if allow_unavailable_host and startup_failed:
1115
+ # A failed host has no live HubStore whose logical state can
1116
+ # be snapshotted. Keep both local and peer admission closed;
1117
+ # the caller must bind the repair transaction to an exact
1118
+ # cold on-disk generation before replacing the release.
1119
+ self.logger.warning(
1120
+ "Team Hub maintenance proceeding without a live snapshot "
1121
+ "reason=%s startup_failure=%s",
1122
+ reason,
1123
+ startup_failure_reason or "unavailable",
1124
+ )
1125
+ return None
1126
+ cleanup_cancellation = await settle_aborted_maintenance()
1127
+ if cleanup_cancellation is not None:
1128
+ raise cleanup_cancellation
1129
+ raise RuntimeError("designated Team Hub is unavailable for maintenance")
1130
+ if persistent_fence and not operation_id:
1131
+ cleanup_cancellation = await settle_aborted_maintenance()
1132
+ if cleanup_cancellation is not None:
1133
+ raise cleanup_cancellation
1134
+ raise ValueError("persistent Team Hub maintenance requires an operation_id")
1135
+ snapshot_call = (
1136
+ asyncio.to_thread(
1137
+ store.maintenance_snapshot_and_fence,
1138
+ reason,
1139
+ operation_id=str(operation_id),
1140
+ )
1141
+ if persistent_fence
1142
+ else asyncio.to_thread(store.maintenance_snapshot, reason)
1143
+ )
1144
+ snapshot_task = asyncio.create_task(
1145
+ snapshot_call
1146
+ )
1147
+ try:
1148
+ return await asyncio.shield(snapshot_task)
1149
+ except asyncio.CancelledError as cancellation:
1150
+ snapshot: Path | None = None
1151
+ try:
1152
+ snapshot = await _join_task_despite_caller_cancellation(
1153
+ snapshot_task
1154
+ )
1155
+ except BaseException as exc:
1156
+ self.logger.error(
1157
+ "Team Hub maintenance snapshot failed during cancellation "
1158
+ "error_type=%s",
1159
+ type(exc).__name__,
1160
+ )
1161
+ await settle_aborted_maintenance(store=store, snapshot=snapshot)
1162
+ raise cancellation
1163
+ except BaseException:
1164
+ cleanup_cancellation = await settle_aborted_maintenance(store=store)
1165
+ if cleanup_cancellation is not None:
1166
+ raise cleanup_cancellation
1167
+ raise
1168
+
1169
+ async def clear_maintenance(
1170
+ self,
1171
+ reason: str,
1172
+ operation_id: str,
1173
+ snapshot: Path,
1174
+ ) -> bool:
1175
+ with self._guard:
1176
+ store = self._store
1177
+ if store is None:
1178
+ if self.designated_host:
1179
+ raise RuntimeError("designated Team Hub is unavailable")
1180
+ return False
1181
+ clear_task = asyncio.create_task(
1182
+ asyncio.to_thread(
1183
+ store.clear_maintenance_fence,
1184
+ expected_reason=reason,
1185
+ expected_operation_id=operation_id,
1186
+ expected_snapshot=snapshot,
1187
+ )
1188
+ )
1189
+ try:
1190
+ return await asyncio.shield(clear_task)
1191
+ except asyncio.CancelledError as cancellation:
1192
+ # The filesystem replace/unlink sequence cannot be cancelled once
1193
+ # its worker has begun. Retain ownership through repeated caller
1194
+ # cancellation so a caller cannot reopen admission while the
1195
+ # durable fence still exists (or while its outcome is unknown).
1196
+ await _join_task_despite_caller_cancellation(clear_task)
1197
+ raise cancellation
1198
+
1199
+ def clear_maintenance_sync(
1200
+ self,
1201
+ reason: str,
1202
+ operation_id: str,
1203
+ snapshot: Path,
1204
+ ) -> bool:
1205
+ with self._guard:
1206
+ store = self._store
1207
+ if store is None:
1208
+ if self.designated_host:
1209
+ raise RuntimeError("designated Team Hub is unavailable")
1210
+ return False
1211
+ return store.clear_maintenance_fence(
1212
+ expected_reason=reason,
1213
+ expected_operation_id=operation_id,
1214
+ expected_snapshot=snapshot,
1215
+ )
1216
+
1217
+ def maintenance_fence_sync(self) -> dict[str, Any] | None:
1218
+ with self._guard:
1219
+ store = self._store
1220
+ if store is None:
1221
+ if self.designated_host:
1222
+ raise RuntimeError("designated Team Hub is unavailable")
1223
+ return None
1224
+ return store.maintenance_fence()
1225
+
1226
+ async def reopen_admission(self) -> None:
1227
+ with self._guard:
1228
+ ready = self._delegate is not None and self._store is not None
1229
+ # Do not expose the local ASGI surface while the secure-peer
1230
+ # surface is still closed or its reopen outcome is unknown.
1231
+ if self.secure_peer_manager is not None:
1232
+ self._accepting = False
1233
+ cancellation: asyncio.CancelledError | None = None
1234
+ if self.secure_peer_manager is not None and ready:
1235
+ reopen_task = asyncio.create_task(
1236
+ asyncio.to_thread(
1237
+ self.secure_peer_manager.reopen_host_admission
1238
+ )
1239
+ )
1240
+ try:
1241
+ await asyncio.shield(reopen_task)
1242
+ except asyncio.CancelledError as exc:
1243
+ try:
1244
+ await _join_task_despite_caller_cancellation(reopen_task)
1245
+ except BaseException:
1246
+ with self._guard:
1247
+ self._accepting = False
1248
+ raise
1249
+ cancellation = exc
1250
+ except BaseException:
1251
+ with self._guard:
1252
+ self._accepting = False
1253
+ raise
1254
+
1255
+ # No await is allowed between a successful peer reopen and publishing
1256
+ # local admission: cancellation must observe both surfaces in the same
1257
+ # state. Notify condition waiters in a separately owned task afterward.
1258
+ with self._guard:
1259
+ self._accepting = ready
1260
+
1261
+ async def notify_reopened() -> None:
1262
+ async with self._admission:
1263
+ self._admission.notify_all()
1264
+
1265
+ notification_task = asyncio.create_task(notify_reopened())
1266
+ try:
1267
+ await asyncio.shield(notification_task)
1268
+ except asyncio.CancelledError as exc:
1269
+ await _join_task_despite_caller_cancellation(notification_task)
1270
+ if cancellation is None:
1271
+ cancellation = exc
1272
+ if cancellation is not None:
1273
+ raise cancellation
1274
+
1275
+ def reopen_admission_sync(self) -> None:
1276
+ """Reopen after a synchronous restart signal worker fails.
1277
+
1278
+ Request admission only holds the asyncio condition long enough to
1279
+ increment a counter; changing the guarded flag cannot strand a waiter.
1280
+ The next request observes the restored value without crossing event
1281
+ loops from Starlette's background thread.
1282
+ """
1283
+
1284
+ with self._guard:
1285
+ ready = self._delegate is not None and self._store is not None
1286
+ if self.secure_peer_manager is not None:
1287
+ self._accepting = False
1288
+ if self.secure_peer_manager is not None and ready:
1289
+ try:
1290
+ self.secure_peer_manager.reopen_host_admission()
1291
+ except BaseException:
1292
+ with self._guard:
1293
+ self._accepting = False
1294
+ raise
1295
+ with self._guard:
1296
+ self._accepting = ready
1297
+
1298
+ async def shutdown(self) -> None:
1299
+ if not self.designated_host:
1300
+ return
1301
+ try:
1302
+ try:
1303
+ with self._guard:
1304
+ store = self._store
1305
+ already_fenced = bool(
1306
+ store is not None
1307
+ and await asyncio.to_thread(store.maintenance_fence) is not None
1308
+ )
1309
+ if already_fenced:
1310
+ await asyncio.wait_for(self._close_and_drain(), timeout=10.0)
1311
+ else:
1312
+ await self.prepare_maintenance(
1313
+ "server-shutdown",
1314
+ persistent_fence=False,
1315
+ _reopen_on_abort=False,
1316
+ )
1317
+ except Exception as exc:
1318
+ self.logger.error(
1319
+ "managed Team Hub shutdown snapshot failed error_type=%s",
1320
+ type(exc).__name__,
1321
+ )
1322
+ async with self._admission:
1323
+ self._accepting = False
1324
+ finally:
1325
+ with self._guard:
1326
+ lease = self._runtime_lease_fd
1327
+ self._runtime_lease_fd = None
1328
+ HubStore.release_managed_runtime_lease(lease)
1329
+
1330
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
1331
+ async with self._admission:
1332
+ with self._guard:
1333
+ delegate = self._delegate if self._accepting else None
1334
+ if delegate is not None:
1335
+ self._in_flight += 1
1336
+ if delegate is None:
1337
+ response = JSONResponse(
1338
+ {
1339
+ "error": {
1340
+ "code": "hub_unavailable",
1341
+ "message": "Team Hub is unavailable",
1342
+ }
1343
+ },
1344
+ status_code=503,
1345
+ )
1346
+ await response(scope, receive, send)
1347
+ return
1348
+ try:
1349
+ await delegate(scope, receive, send)
1350
+ finally:
1351
+ async with self._admission:
1352
+ self._in_flight = max(0, self._in_flight - 1)
1353
+ if self._in_flight == 0:
1354
+ self._admission.notify_all()