@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,615 @@
1
+ """Explicit native activation of separately managed gateway/execution jobs.
2
+
3
+ This module has no import-time service effects. The CLI's apply/recover commands
4
+ are mutating installer operations; inspect is read-only. Native service changes
5
+ are serialized with install.sh's .install-lock. Every worker stop requires a
6
+ fresh, sealed admission hold for that exact worker epoch. A legacy monolith
7
+ without the private control contract must enter through its existing managed
8
+ idle-update handoff; direct migration of that running process is refused here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import base64
15
+ import errno
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ import re
20
+ import secrets
21
+ import shlex
22
+ import stat
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from typing import Any, Callable
27
+ import urllib.parse
28
+
29
+ import execution_install as files
30
+ from execution_transport import _read_secret
31
+ from execution_http import request_json
32
+
33
+
34
+ class InstallationLock:
35
+ """Use the same durable PID-directory namespace as the legacy installer."""
36
+
37
+ def __init__(self, root: Path) -> None:
38
+ self.root = files._path(root)
39
+ self.path = self.root / ".install-lock"
40
+ self.identity: tuple[int, int] | None = None
41
+
42
+ def __enter__(self) -> "InstallationLock":
43
+ files._owned_directory(self.root)
44
+ draft = self.root / f".install-lock.{os.getpid()}.{secrets.token_hex(12)}.tmp"
45
+ draft.mkdir(mode=0o700)
46
+ files._atomic_write(draft / "pid", f"{os.getpid()}\n".encode())
47
+ try:
48
+ for _ in range(16):
49
+ try:
50
+ os.rename(draft, self.path)
51
+ except OSError as exc:
52
+ if exc.errno not in {errno.EEXIST, errno.ENOTEMPTY}:
53
+ raise
54
+ self._reap_dead_owner()
55
+ else:
56
+ info = self.path.lstat()
57
+ self.identity = info.st_dev, info.st_ino
58
+ files._fsync_directory(self.root)
59
+ return self
60
+ raise RuntimeError("installation lock ownership kept changing")
61
+ finally:
62
+ if draft.exists():
63
+ (draft / "pid").unlink()
64
+ draft.rmdir()
65
+
66
+ def _reap_dead_owner(self) -> None:
67
+ files._owned_directory(self.path, private=True)
68
+ fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
69
+ try:
70
+ info = os.fstat(fd)
71
+ try:
72
+ owner_fd = os.open("pid", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd)
73
+ except FileNotFoundError:
74
+ if os.listdir(fd):
75
+ raise RuntimeError("installation lock contains unknown files")
76
+ return # atomic rename may replace this exact empty directory
77
+ try:
78
+ owner_info = os.fstat(owner_fd)
79
+ if (not stat.S_ISREG(owner_info.st_mode) or owner_info.st_uid != os.getuid()
80
+ or owner_info.st_nlink != 1 or stat.S_IMODE(owner_info.st_mode) not in {0o600, 0o644}):
81
+ raise PermissionError("installation lock owner is unsafe")
82
+ raw = os.read(owner_fd, 33)
83
+ finally:
84
+ os.close(owner_fd)
85
+ if not re.fullmatch(rb"[0-9]{1,20}\n?", raw) or int(raw) <= 1:
86
+ raise ValueError("installation lock owner is invalid")
87
+ try:
88
+ os.kill(int(raw), 0)
89
+ except ProcessLookupError:
90
+ pass
91
+ except PermissionError as exc:
92
+ raise RuntimeError("another installer owns the installation lock") from exc
93
+ else:
94
+ raise RuntimeError("another installer owns the installation lock")
95
+ current = os.stat("pid", dir_fd=fd, follow_symlinks=False)
96
+ if (current.st_dev, current.st_ino) != (owner_info.st_dev, owner_info.st_ino):
97
+ return
98
+ # unlinkat is pinned to the dead owner's directory, never a newer lock.
99
+ os.unlink("pid", dir_fd=fd)
100
+ os.fsync(fd)
101
+ linked = self.path.lstat()
102
+ if (linked.st_dev, linked.st_ino) != (info.st_dev, info.st_ino):
103
+ return
104
+ finally:
105
+ os.close(fd)
106
+
107
+ def __exit__(self, *unused: Any) -> None:
108
+ if self.identity is None:
109
+ return
110
+ files._owned_directory(self.path, private=True)
111
+ fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
112
+ try:
113
+ info = os.fstat(fd)
114
+ if (info.st_dev, info.st_ino) != self.identity:
115
+ raise RuntimeError("installation lock identity changed")
116
+ data, _mode = files._read_file(self.path / "pid", private=True)
117
+ if data != f"{os.getpid()}\n".encode() or set(os.listdir(fd)) != {"pid"}:
118
+ raise RuntimeError("installation lock owner changed")
119
+ os.unlink("pid", dir_fd=fd)
120
+ os.fsync(fd)
121
+ finally:
122
+ os.close(fd)
123
+ info = self.path.lstat()
124
+ if (info.st_dev, info.st_ino) != self.identity:
125
+ raise RuntimeError("installation lock path changed")
126
+ self.path.rmdir()
127
+ files._fsync_directory(self.root)
128
+ self.identity = None
129
+
130
+
131
+ class NativeServices:
132
+ def __init__(self, layout: files.ExecutionLayout, *, run: Callable[..., Any] | None = None,
133
+ timeout: float = 180.0) -> None:
134
+ self.layout = layout
135
+ self.run = run or subprocess.run
136
+ self.timeout = timeout
137
+ if run is None and (sys.platform not in {"darwin", "linux"}
138
+ or ((layout.platform == "Darwin") != (sys.platform == "darwin"))):
139
+ raise ValueError("native execution platform does not match this host")
140
+
141
+ def _command(self, args: list[str], *, allow_failure: bool = False, timeout: float = 10) -> Any:
142
+ result = self.run(args, stdin=subprocess.DEVNULL, capture_output=True, text=True,
143
+ timeout=timeout, check=False)
144
+ if result.returncode and not allow_failure:
145
+ # launchctl print can contain environment secrets. Never echo output.
146
+ raise RuntimeError(f"native service command failed ({args[0]} {args[1]})")
147
+ return result
148
+
149
+ def _unit(self, role: str) -> str:
150
+ return files.WORKER_UNIT if role == "worker" else files.GATEWAY_UNIT
151
+
152
+ def _target(self, role: str) -> str:
153
+ label = files.WORKER_LABEL if role == "worker" else files.GATEWAY_LABEL
154
+ return f"gui/{os.getuid()}/{label}"
155
+
156
+ def _observe(self, role: str) -> dict[str, Any]:
157
+ path = self.layout.service_path(role)
158
+ exists = files._snapshot(path)["exists"]
159
+ if self.layout.platform == "Linux":
160
+ result = self._command(["systemctl", "--user", "show", self._unit(role), "--no-pager",
161
+ "--property=LoadState,ActiveState,UnitFileState,MainPID"])
162
+ values: dict[str, str] = {}
163
+ for line in result.stdout.splitlines():
164
+ key, separator, value = line.partition("=")
165
+ if not separator or key in values:
166
+ raise RuntimeError("systemd returned ambiguous service ownership")
167
+ values[key] = value
168
+ if set(values) != {"LoadState", "ActiveState", "UnitFileState", "MainPID"}:
169
+ raise RuntimeError("systemd omitted required service ownership")
170
+ if values["LoadState"] not in {"loaded", "not-found", "bad-setting", "error"}:
171
+ raise RuntimeError("systemd service is not safely manageable")
172
+ if not values["MainPID"].isdigit():
173
+ raise RuntimeError("systemd returned an invalid service PID")
174
+ if values["UnitFileState"] not in {"enabled", "enabled-runtime", "disabled", "static", ""}:
175
+ raise RuntimeError("systemd service enablement is unsupported")
176
+ pid = int(values["MainPID"])
177
+ enabled = values["UnitFileState"] in {"enabled", "enabled-runtime"}
178
+ active = values["ActiveState"]
179
+ if values["LoadState"] in {"bad-setting", "error"}:
180
+ # A malformed staged job can be restored only after the manager
181
+ # proves no main process is active. Exact config ownership is
182
+ # separately checked against the activation journal by recover.
183
+ if exists and pid == 0 and active in {"inactive", "failed"}:
184
+ return {"state": "stopped", "enabled": enabled}
185
+ raise RuntimeError("invalid native service still has an active process")
186
+ if not exists:
187
+ if values["LoadState"] != "not-found" or pid or active not in {"inactive", "failed"}:
188
+ raise RuntimeError("loaded native service has no safely restorable file")
189
+ return {"state": "absent", "enabled": False}
190
+ if active == "active" and pid > 0:
191
+ return {"state": "running", "enabled": enabled, "pid": pid}
192
+ if active in {"inactive", "failed"} and pid == 0:
193
+ return {"state": "stopped", "enabled": enabled}
194
+ return {"state": "transitioning", "enabled": enabled}
195
+ disabled = self._command(["/bin/launchctl", "print-disabled", f"gui/{os.getuid()}"])
196
+ label = self._target(role).rsplit("/", 1)[1]
197
+ entries = re.findall(r'(?m)^\s*"' + re.escape(label) + r'"\s*=>\s*(\S+)\s*$', disabled.stdout)
198
+ if len(entries) > 1 or (entries and entries[0] not in {"true", "false", "enabled", "disabled"}):
199
+ raise RuntimeError("launchd returned ambiguous enablement")
200
+ enabled = not entries or entries[0] in {"false", "enabled"}
201
+ result = self._command(["/bin/launchctl", "print", self._target(role)], allow_failure=True)
202
+ if result.returncode:
203
+ if "Could not find service" not in result.stderr:
204
+ raise RuntimeError("launchd service ownership is unknown")
205
+ return {"state": "stopped" if exists else "absent", "enabled": enabled if exists else False}
206
+ if not exists:
207
+ raise RuntimeError("loaded native service has no safely restorable plist")
208
+ pids = re.findall(r"(?m)^\s*pid\s*=\s*([0-9]+)\s*$", result.stdout)
209
+ if len(pids) == 1 and int(pids[0]) > 0:
210
+ return {"state": "running", "enabled": enabled, "pid": int(pids[0])}
211
+ return {"state": "transitioning", "enabled": enabled}
212
+
213
+ def snapshot(self) -> dict[str, Any]:
214
+ states = {role: self._observe(role) for role in ("worker", "gateway")}
215
+ if any(item["state"] == "transitioning" for item in states.values()):
216
+ raise RuntimeError("native service is transitioning; retry after it settles")
217
+ return states
218
+
219
+ def stop(self, role: str) -> None:
220
+ state = self._observe(role)
221
+ if state["state"] in {"absent", "stopped"}:
222
+ return
223
+ if self.layout.platform == "Linux":
224
+ self._command(["systemctl", "--user", "stop", "--no-block", self._unit(role)])
225
+ else:
226
+ self._command(["/bin/launchctl", "bootout", self._target(role)],
227
+ timeout=self.timeout if role == "worker" else min(self.timeout, 30))
228
+ deadline = time.monotonic() + (self.timeout if role == "worker" else min(self.timeout, 30))
229
+ while time.monotonic() < deadline:
230
+ if self._observe(role)["state"] in {"absent", "stopped"}:
231
+ return
232
+ time.sleep(0.1)
233
+ raise RuntimeError("native service did not stop; no force kill was attempted")
234
+
235
+ def reload(self) -> None:
236
+ if self.layout.platform == "Linux":
237
+ self._command(["systemctl", "--user", "daemon-reload"])
238
+
239
+ def set_enabled(self, role: str, enabled: bool) -> None:
240
+ if self.layout.platform == "Linux":
241
+ self._command(["systemctl", "--user", "enable" if enabled else "disable", self._unit(role)])
242
+ else:
243
+ self._command(["/bin/launchctl", "enable" if enabled else "disable", self._target(role)])
244
+
245
+ def start(self, role: str) -> None:
246
+ files._read_file(self.layout.service_path(role))
247
+ self.set_enabled(role, True)
248
+ if self.layout.platform == "Linux":
249
+ self._command(["systemctl", "--user", "start", "--no-block", self._unit(role)])
250
+ else:
251
+ self._command(["/bin/launchctl", "bootstrap", f"gui/{os.getuid()}", str(self.layout.service_path(role))])
252
+
253
+ def restore(self, role: str, prior: dict[str, Any]) -> None:
254
+ if prior["state"] == "absent":
255
+ return
256
+ if prior["state"] == "running":
257
+ self.start(role)
258
+ if prior["state"] != "running" or not prior["enabled"]:
259
+ self.set_enabled(role, prior["enabled"])
260
+
261
+
262
+ class WorkerControl:
263
+ def __init__(self, *, request_timeout: float = 5.0, services: NativeServices | None = None) -> None:
264
+ self.timeout = request_timeout
265
+ self.services = services
266
+
267
+ def _native(self, layout: files.ExecutionLayout) -> dict[str, Any]:
268
+ return (self.services or NativeServices(layout)).snapshot()
269
+
270
+ @staticmethod
271
+ def _running_pid(native: dict, role: str) -> int:
272
+ value = native[role]
273
+ if value.get("state") != "running" or type(value.get("pid")) is not int or value["pid"] <= 1:
274
+ raise RuntimeError("credential endpoint has no running native owner")
275
+ return value["pid"]
276
+
277
+ def _json(self, url: str, token: str, body: dict[str, Any] | None = None, *,
278
+ expected_pid: int, platform: str, verify_owner: Callable[[], None]) -> dict[str, Any]:
279
+ return request_json(url, token, body=body, expected_pid=expected_pid, platform=platform,
280
+ verify_owner=verify_owner, timeout=self.timeout, maximum_body=files.MAX_CONFIG_BYTES)
281
+
282
+ def _callback_request(self, layout: files.ExecutionLayout, record: dict, path: str,
283
+ token: str, body: dict | None = None) -> dict:
284
+ def verify_owner():
285
+ if (self._running_pid(self._native(layout), "worker") != record["pid"]
286
+ or self.worker_record(layout) != record):
287
+ raise RuntimeError("native worker or callback receipt changed before credentials were sent")
288
+ verify_owner()
289
+ return self._json(record["callback_origin"] + path, token, body,
290
+ expected_pid=record["pid"], platform=layout.platform, verify_owner=verify_owner)
291
+
292
+ def callback_health(self, layout: files.ExecutionLayout, record: dict) -> dict:
293
+ return self._callback_request(layout, record, "/api/health", self._agent_token(layout))
294
+
295
+ def worker_record(self, layout: files.ExecutionLayout) -> dict[str, Any]:
296
+ files._owned_directory(layout.runtime_dir, private=True)
297
+ data, _mode = files._read_file(layout.runtime_dir / "worker.json", private=True)
298
+ record = json.loads(data)
299
+ if (not isinstance(record, dict) or record.get("role") != "worker"
300
+ or record.get("protocol") != files.PROTOCOL_VERSION
301
+ or type(record.get("pid")) is not int or record["pid"] <= 0
302
+ or not isinstance(record.get("instance_id"), str) or not record["instance_id"]):
303
+ raise RuntimeError("worker process receipt is invalid")
304
+ origin = urllib.parse.urlsplit(record.get("callback_origin", ""))
305
+ if (origin.scheme != "http" or origin.hostname != "127.0.0.1" or not origin.port
306
+ or origin.path or origin.query or origin.fragment or origin.username or origin.password):
307
+ raise RuntimeError("worker callback receipt is not an exact loopback origin")
308
+ release = files._path(record.get("release_root", ""))
309
+ if release.parent != layout.install_root / "releases":
310
+ raise RuntimeError("worker receipt release is outside this installation")
311
+ return record
312
+
313
+ def status(self, layout: files.ExecutionLayout) -> tuple[dict[str, Any], dict[str, Any]]:
314
+ record = self.worker_record(layout)
315
+ result = self._callback_request(layout, record, "/api/admin/execution/status",
316
+ _read_secret(layout.runtime_dir / "control.token"))
317
+ if result.get("worker_instance_id") != record["instance_id"] or type(result.get("idle")) is not bool:
318
+ raise RuntimeError("private worker status does not match its process receipt")
319
+ return record, result
320
+
321
+ def _maintenance(self, layout: files.ExecutionLayout, record: dict[str, Any], *, action: str,
322
+ operation: str, lease_id: str | None = None) -> dict[str, Any]:
323
+ payload = {"action": action, "expected_worker_instance_id": record["instance_id"],
324
+ "operation_id": operation}
325
+ if lease_id is not None:
326
+ payload["lease_id"] = lease_id
327
+ result = self._callback_request(layout, record, "/api/admin/execution/maintenance",
328
+ _read_secret(layout.runtime_dir / "control.token"), payload)
329
+ if result.get("worker_instance_id") != record["instance_id"]:
330
+ raise RuntimeError("worker changed during maintenance admission")
331
+ return result
332
+
333
+ def seal_for_stop(self, layout: files.ExecutionLayout, operation: str, native_pid: int) -> dict[str, Any]:
334
+ record, status = self.status(layout)
335
+ if record["pid"] != native_pid:
336
+ raise RuntimeError("native service does not own the worker that granted admission")
337
+ lease = status.get("lease")
338
+ if lease is None:
339
+ status = self._maintenance(layout, record, action="acquire", operation=operation)
340
+ lease = status.get("lease")
341
+ if (not isinstance(lease, dict) or lease.get("operation_id") != operation
342
+ or not isinstance(lease.get("lease_id"), str) or not lease["lease_id"]):
343
+ raise RuntimeError("another operation owns worker maintenance admission")
344
+ sealed = self._maintenance(layout, record, action="seal", operation=operation, lease_id=lease["lease_id"])
345
+ final = sealed.get("lease")
346
+ if (sealed.get("idle") is not True or not isinstance(final, dict)
347
+ or final.get("lease_id") != lease["lease_id"] or final.get("operation_id") != operation
348
+ or final.get("sealed") is not True):
349
+ raise RuntimeError("worker did not seal an idle admission hold")
350
+ return {"record": record, "lease": final}
351
+
352
+ def require_startup_hold(self, layout: files.ExecutionLayout, operation: str) -> None:
353
+ _record, status = self.status(layout)
354
+ lease = status.get("lease")
355
+ if (status.get("idle") is not True or not isinstance(lease, dict)
356
+ or lease.get("operation_id") != operation or lease.get("sealed") is not True):
357
+ raise RuntimeError("candidate worker did not retain its activation startup hold")
358
+
359
+ def release(self, layout: files.ExecutionLayout, operation: str) -> None:
360
+ record, status = self.status(layout)
361
+ lease = status.get("lease")
362
+ if lease is None:
363
+ return
364
+ if not isinstance(lease, dict) or lease.get("operation_id") != operation:
365
+ raise RuntimeError("another operation owns the worker admission hold")
366
+ result = self._maintenance(layout, record, action="release", operation=operation,
367
+ lease_id=lease.get("lease_id"))
368
+ if result.get("lease") is not None:
369
+ raise RuntimeError("worker admission hold was not released")
370
+
371
+ def _agent_token(self, layout: files.ExecutionLayout) -> str:
372
+ values = dict(layout.environment)
373
+ try:
374
+ data, _mode = files._read_file(layout.config_root / "env", private=True)
375
+ except FileNotFoundError:
376
+ pass
377
+ else:
378
+ for line in data.decode().splitlines():
379
+ if not line.strip() or line.lstrip().startswith("#"):
380
+ continue
381
+ key, separator, value = line.partition("=")
382
+ if separator:
383
+ parsed = shlex.split(value)
384
+ if len(parsed) == 1:
385
+ values[key] = parsed[0]
386
+ for key in ("AGENTSDOCK_AGENT_TOKEN", "ZENITHDOCK_AGENT_TOKEN", "ZENITHBOT_AGENT_TOKEN", "AGENT_TOKEN"):
387
+ if values.get(key):
388
+ return values[key]
389
+ raise RuntimeError("authenticated activation health requires the preserved server token")
390
+
391
+ def health(self, layout: files.ExecutionLayout) -> dict[str, Any]:
392
+ host = ("127.0.0.1" if layout.bind in {"0.0.0.0", "localhost"} else
393
+ "::1" if layout.bind in {"::", "[::]", "::1", "[::1]"} else layout.bind)
394
+ if ":" in host:
395
+ host = f"[{host}]"
396
+ native = self._native(layout)
397
+ role = "gateway" if native["gateway"]["state"] == "running" else "worker"
398
+ if role == "worker" and (native["gateway"]["state"] != "absent"
399
+ or layout.manifest_path.exists() or layout.manifest_path.is_symlink()):
400
+ raise RuntimeError("public credentials require the installed native gateway")
401
+ pid = self._running_pid(native, role)
402
+ def verify_owner():
403
+ if self._running_pid(self._native(layout), role) != pid:
404
+ raise RuntimeError("native health endpoint changed before credentials were sent")
405
+ return self._json(f"http://{host}:{layout.port}/api/health", self._agent_token(layout),
406
+ expected_pid=pid, platform=layout.platform, verify_owner=verify_owner)
407
+
408
+ def receipt(self, layout: files.ExecutionLayout, services: NativeServices) -> dict[str, Any]:
409
+ health = self.health(layout)
410
+ record = self.worker_record(layout)
411
+ gateway, worker = health.get("gateway"), health.get("execution_service")
412
+ if (health.get("ok") is not True or not isinstance(gateway, dict) or not isinstance(worker, dict)
413
+ or gateway.get("protocol") != files.PROTOCOL_VERSION
414
+ or worker.get("protocol") != files.PROTOCOL_VERSION
415
+ or worker.get("instance_id") != record["instance_id"] or worker.get("pid") != record["pid"]
416
+ or record.get("release_root") != str(layout.worker_release)):
417
+ raise RuntimeError("authenticated gateway/worker health does not match owned runtime receipts")
418
+ native = services.snapshot()
419
+ if (native["worker"].get("pid") != worker.get("pid")
420
+ or native["gateway"].get("pid") != gateway.get("pid")):
421
+ raise RuntimeError("native services do not own the authenticated component processes")
422
+ return {"gateway_version": gateway.get("version"), "worker_version": worker.get("version"),
423
+ "worker_release": record["release_root"], "protocol_version": files.PROTOCOL_VERSION,
424
+ "server_identity": health.get("server_identity"), "gateway_pid": gateway.get("pid"),
425
+ "worker_pid": worker.get("pid"), "worker_instance_id": worker.get("instance_id"),
426
+ "maintenance_held": worker.get("maintenance_held")}
427
+
428
+
429
+ class ActivationController:
430
+ def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
431
+ control: WorkerControl | None = None, health_timeout: float = 30.0) -> None:
432
+ self.layout = layout
433
+ self.services = services or NativeServices(layout)
434
+ self.control = control or WorkerControl()
435
+ self.health_timeout = health_timeout
436
+
437
+ def _wait_receipt(self, layout: files.ExecutionLayout) -> dict[str, Any]:
438
+ deadline = time.monotonic() + self.health_timeout
439
+ error: Exception | None = None
440
+ while True:
441
+ try:
442
+ receipt = self.control.receipt(layout, self.services)
443
+ expected = files.layout_manifest(layout)
444
+ if (receipt.get("gateway_version") != expected["gateway_version"]
445
+ or receipt.get("worker_version") != expected["worker_version"]):
446
+ raise RuntimeError("authenticated component versions are not the requested release")
447
+ return receipt
448
+ except (OSError, ValueError, RuntimeError) as exc:
449
+ error = exc
450
+ if time.monotonic() >= deadline:
451
+ raise RuntimeError("execution components did not reach authenticated health") from error
452
+ time.sleep(0.1)
453
+
454
+ def _stop_worker(self, transaction: dict[str, Any]) -> None:
455
+ state = self.services.snapshot()["worker"]
456
+ if state["state"] == "running":
457
+ self.control.seal_for_stop(self.layout, files.maintenance_operation_id(transaction), state["pid"])
458
+ current = self.services.snapshot()["worker"]
459
+ if current.get("pid") != state["pid"]:
460
+ raise RuntimeError("worker changed after sealing; no stop was attempted")
461
+ self.services.stop("worker")
462
+
463
+ @staticmethod
464
+ def _check_identity(transaction: dict[str, Any], receipt: dict[str, Any]) -> None:
465
+ expected = transaction["expected_server_identity"]
466
+ if expected is not None and receipt.get("server_identity") != expected:
467
+ raise RuntimeError("authenticated server identity changed during activation recovery")
468
+
469
+ def apply(self, scope: str) -> dict[str, Any]:
470
+ """Caller must own InstallationLock (including recovery on exceptions)."""
471
+ self.layout.validate()
472
+ prior = self.services.snapshot()
473
+ expected_identity = None
474
+ if prior["gateway"]["state"] == "running":
475
+ expected_identity = self.control.health(self.layout).get("server_identity")
476
+ if not isinstance(expected_identity, str) or not expected_identity:
477
+ raise RuntimeError("current authenticated server identity is unavailable")
478
+ if prior["worker"]["state"] == "running":
479
+ # Refuse a direct legacy migration before staging anything. Its
480
+ # existing normal-update handoff needs a separate verified bridge.
481
+ record, _status = self.control.status(self.layout)
482
+ if record["pid"] != prior["worker"]["pid"]:
483
+ raise RuntimeError("native worker ownership does not match its private receipt")
484
+ if scope != "gateway" and _status.get("lease") is not None:
485
+ raise RuntimeError("another execution operation already owns worker admission")
486
+ prior["worker"]["instance_id"] = record["instance_id"]
487
+ transaction = files.stage(self.layout, scope=scope, prior_services=prior,
488
+ expected_server_identity=expected_identity)
489
+ operation = files.maintenance_operation_id(transaction)
490
+ changed_services = False
491
+ try:
492
+ files.prepare_runtime(self.layout)
493
+ if scope in {"migration", "worker"} and prior["worker"]["state"] == "running":
494
+ # Acquire + seal before ANY stop, including the public gateway.
495
+ self.control.seal_for_stop(self.layout, operation, prior["worker"]["pid"])
496
+ if scope in {"migration", "gateway"}:
497
+ changed_services = True
498
+ self.services.stop("gateway")
499
+ if scope in {"migration", "worker"}:
500
+ changed_services = True
501
+ self._stop_worker(transaction)
502
+ files.publish(self.layout.install_root)
503
+ self.services.reload()
504
+ if scope in {"migration", "worker"}:
505
+ self.services.start("worker")
506
+ if scope in {"migration", "gateway"}:
507
+ self.services.start("gateway")
508
+ receipt = self._wait_receipt(self.layout)
509
+ if scope != "gateway":
510
+ self.control.require_startup_hold(self.layout, operation)
511
+ files.commit(self.layout.install_root, receipt)
512
+ if scope != "gateway":
513
+ self.control.release(self.layout, operation)
514
+ files.finish(self.layout.install_root)
515
+ return {"ok": True, "scope": scope, "receipt": receipt}
516
+ except Exception:
517
+ # The commit boundary is irreversible. Failed finalization leaves a
518
+ # terminal journal and hold so recover can retry without rollback.
519
+ pending, _layout = files._load(self.layout.install_root)
520
+ if pending["phase"] == "committed":
521
+ raise
522
+ if not changed_services:
523
+ files.rollback(self.layout.install_root)
524
+ if scope != "gateway" and prior["worker"]["state"] == "running":
525
+ _record, status = self.control.status(self.layout)
526
+ lease = status.get("lease")
527
+ # A private maintenance request can win admission between
528
+ # preflight and seal. No service changed: retire only our
529
+ # journal and leave that other operation's hold intact.
530
+ if isinstance(lease, dict) and lease.get("operation_id") == operation:
531
+ self.control.release(self.layout, operation)
532
+ files.finish(self.layout.install_root)
533
+ else:
534
+ self.recover()
535
+ raise
536
+
537
+ def recover(self) -> dict[str, Any]:
538
+ """Restore a pending activation, or finalize an already committed one."""
539
+ transaction, layout = files._load(self.layout.install_root)
540
+ if layout != self.layout:
541
+ raise ValueError("controller layout differs from the pending activation")
542
+ files._check_publication(transaction, layout)
543
+ operation = files.maintenance_operation_id(transaction)
544
+ scope = transaction["scope"]
545
+ if transaction["phase"] == "committed":
546
+ receipt = self._wait_receipt(layout)
547
+ self._check_identity(transaction, receipt)
548
+ if scope == "gateway":
549
+ prior = transaction["prior_services"]["worker"]
550
+ if prior["state"] == "running" and (receipt["worker_pid"] != prior["pid"]
551
+ or receipt["worker_instance_id"] != prior["instance_id"]):
552
+ raise RuntimeError("retained worker changed before gateway finalization")
553
+ else:
554
+ self.control.release(layout, operation)
555
+ files.finish(layout.install_root)
556
+ return {"ok": True, "phase": "committed", "receipt": receipt}
557
+ if transaction["phase"] != "rolled-back":
558
+ if scope in {"migration", "worker"}:
559
+ self._stop_worker(transaction)
560
+ if scope in {"migration", "gateway"}:
561
+ self.services.stop("gateway")
562
+ files.rollback(layout.install_root)
563
+ self.services.reload()
564
+ affected = ("worker", "gateway") if scope == "migration" else (scope,)
565
+ for role in affected:
566
+ # Recovery may be retried after the old service was already started.
567
+ current = self.services.snapshot()[role]
568
+ prior = transaction["prior_services"][role]
569
+ if prior["state"] == "running" and current["state"] == "running":
570
+ continue
571
+ self.services.restore(role, prior)
572
+ previous = transaction["before"]["layout"]
573
+ receipt = None
574
+ if previous["exists"]:
575
+ old_layout = files.ExecutionLayout.from_dict(json.loads(base64.b64decode(previous["data"]))["layout"])
576
+ if all(transaction["prior_services"][role]["state"] == "running" for role in ("worker", "gateway")):
577
+ receipt = self._wait_receipt(old_layout)
578
+ self._check_identity(transaction, receipt)
579
+ elif transaction["prior_services"]["worker"]["state"] == "running":
580
+ # A legacy normal-update handoff needs its original health validator;
581
+ # do not claim this isolated controller restored that boundary yet.
582
+ raise RuntimeError("legacy migration rollback requires managed updater health verification")
583
+ if scope != "gateway" and transaction["prior_services"]["worker"]["state"] == "running":
584
+ self.control.release(layout, operation)
585
+ files.finish(layout.install_root)
586
+ return {"ok": True, "phase": "rolled-back", "receipt": receipt}
587
+
588
+
589
+ def main(argv: list[str] | None = None) -> None:
590
+ parser = argparse.ArgumentParser(description=__doc__)
591
+ commands = parser.add_subparsers(dest="command", required=True)
592
+ apply_parser = commands.add_parser("apply")
593
+ apply_parser.add_argument("--layout", type=Path, required=True)
594
+ apply_parser.add_argument("--scope", choices=("migration", "gateway", "worker"), required=True)
595
+ for name in ("recover", "inspect"):
596
+ commands.add_parser(name).add_argument("--root", type=Path, required=True)
597
+ args = parser.parse_args(argv)
598
+ if args.command == "apply":
599
+ data, _mode = files._read_file(args.layout, private=True)
600
+ layout = files.ExecutionLayout.from_dict(json.loads(data))
601
+ with InstallationLock(layout.install_root):
602
+ result = ActivationController(layout).apply(args.scope)
603
+ else:
604
+ transaction, layout = files._load(args.root)
605
+ if args.command == "inspect":
606
+ result = {"id": transaction["id"], "scope": transaction["scope"], "phase": transaction["phase"],
607
+ "operation_id": files.maintenance_operation_id(transaction)}
608
+ else:
609
+ with InstallationLock(layout.install_root):
610
+ result = ActivationController(layout).recover()
611
+ print(json.dumps(result, sort_keys=True))
612
+
613
+
614
+ if __name__ == "__main__":
615
+ main()