@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- 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()
|