@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,792 @@
|
|
|
1
|
+
"""Render and journal the two-service execution layout without running services.
|
|
2
|
+
|
|
3
|
+
The installer owns native service-manager commands and authenticated health
|
|
4
|
+
probes. This module owns deterministic configuration and recoverable filesystem
|
|
5
|
+
publication only. Importing it never starts, stops, or inspects a service.
|
|
6
|
+
|
|
7
|
+
``stage`` snapshots both jobs, the gateway current link, and the previous layout.
|
|
8
|
+
``publish`` and ``rollback`` are resumable after interruption. A caller must stop
|
|
9
|
+
the affected jobs before publication/restoration and pass actual probe results to
|
|
10
|
+
``commit``. Gateway-only operations never rewrite or stop the worker job.
|
|
11
|
+
The caller must hold the installation lock across these operations and native
|
|
12
|
+
service changes. The journal excludes another activation; it is not a substitute
|
|
13
|
+
for that lock when resuming an operation from another process.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import base64
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
import plistlib
|
|
26
|
+
import re
|
|
27
|
+
import secrets
|
|
28
|
+
import stat
|
|
29
|
+
from typing import Any
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
FORMAT = 1
|
|
34
|
+
PROTOCOL_VERSION = 1
|
|
35
|
+
WORKER_LABEL = "com.agentsdock.server"
|
|
36
|
+
GATEWAY_LABEL = "com.agentsdock.gateway"
|
|
37
|
+
WORKER_UNIT = "agents-server.service"
|
|
38
|
+
GATEWAY_UNIT = "agents-server-gateway.service"
|
|
39
|
+
TRANSACTION_NAME = ".execution-transaction"
|
|
40
|
+
LAYOUT_NAME = "execution-layout.json"
|
|
41
|
+
MAX_CONFIG_BYTES = 1_048_576
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def maintenance_operation_id(transaction: dict[str, Any]) -> str:
|
|
45
|
+
identifier = transaction.get("id")
|
|
46
|
+
if not isinstance(identifier, str) or not re.fullmatch(r"execution-[0-9a-f]{24}", identifier):
|
|
47
|
+
raise ValueError("invalid execution transaction identity")
|
|
48
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, "agentsdock-execution:" + identifier))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text(value: Any) -> str:
|
|
52
|
+
if not isinstance(value, str) or not value or any(ord(c) < 32 for c in value):
|
|
53
|
+
raise ValueError("configuration text must be nonempty and contain no control characters")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _path(value: str | Path) -> Path:
|
|
58
|
+
result = Path(value)
|
|
59
|
+
if not result.is_absolute() or ".." in result.parts:
|
|
60
|
+
raise ValueError("execution paths must be absolute without traversal")
|
|
61
|
+
if result.resolve(strict=False) != result:
|
|
62
|
+
raise ValueError(f"execution path contains a symlink: {result}")
|
|
63
|
+
_text(str(result))
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _owned_directory(path: Path, *, private: bool = False) -> None:
|
|
68
|
+
info = path.lstat()
|
|
69
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid():
|
|
70
|
+
raise PermissionError(f"execution directory is not an owned directory: {path}")
|
|
71
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
72
|
+
if mode & 0o022 or (private and mode != 0o700):
|
|
73
|
+
raise PermissionError(f"execution directory permissions are unsafe: {path}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ensure_directory(path: Path, *, private: bool = False) -> None:
|
|
77
|
+
_path(path)
|
|
78
|
+
if not path.exists():
|
|
79
|
+
_ensure_directory(path.parent)
|
|
80
|
+
path.mkdir(mode=0o700)
|
|
81
|
+
_fsync_directory(path.parent)
|
|
82
|
+
_owned_directory(path, private=private)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fsync_directory(path: Path) -> None:
|
|
86
|
+
fd = os.open(path, os.O_RDONLY)
|
|
87
|
+
try:
|
|
88
|
+
os.fsync(fd)
|
|
89
|
+
finally:
|
|
90
|
+
os.close(fd)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_file(path: Path, *, private: bool = False) -> tuple[bytes, int]:
|
|
94
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
95
|
+
try:
|
|
96
|
+
info = os.fstat(fd)
|
|
97
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
98
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
99
|
+
or info.st_nlink != 1 or mode & 0o022
|
|
100
|
+
or (private and mode != 0o600) or info.st_size > MAX_CONFIG_BYTES):
|
|
101
|
+
raise PermissionError(f"execution configuration is unsafe: {path}")
|
|
102
|
+
with os.fdopen(os.dup(fd), "rb") as stream:
|
|
103
|
+
data = stream.read(MAX_CONFIG_BYTES + 1)
|
|
104
|
+
current = path.lstat()
|
|
105
|
+
if (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino):
|
|
106
|
+
raise RuntimeError("execution configuration changed while reading")
|
|
107
|
+
if len(data) > MAX_CONFIG_BYTES:
|
|
108
|
+
raise ValueError("execution configuration exceeds size limit")
|
|
109
|
+
return data, mode
|
|
110
|
+
finally:
|
|
111
|
+
os.close(fd)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
115
|
+
_ensure_directory(path.parent)
|
|
116
|
+
temporary = path.parent / f".{path.name}.{secrets.token_hex(12)}.tmp"
|
|
117
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
|
|
118
|
+
try:
|
|
119
|
+
with os.fdopen(fd, "wb") as stream:
|
|
120
|
+
os.fchmod(stream.fileno(), mode)
|
|
121
|
+
stream.write(data)
|
|
122
|
+
stream.flush()
|
|
123
|
+
os.fsync(stream.fileno())
|
|
124
|
+
os.replace(temporary, path)
|
|
125
|
+
_fsync_directory(path.parent)
|
|
126
|
+
finally:
|
|
127
|
+
if temporary.exists():
|
|
128
|
+
temporary.unlink()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _json_bytes(value: Any) -> bytes:
|
|
132
|
+
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _release(path: Path, root: Path) -> str:
|
|
136
|
+
path = _path(path)
|
|
137
|
+
if path.parent != root / "releases" or path.name.startswith("."):
|
|
138
|
+
raise ValueError("execution runtime must be a retained versioned release")
|
|
139
|
+
_owned_directory(root)
|
|
140
|
+
_owned_directory(path.parent)
|
|
141
|
+
_owned_directory(path)
|
|
142
|
+
data, _mode = _read_file(path / "VERSION")
|
|
143
|
+
version = data.decode().strip()
|
|
144
|
+
_text(version)
|
|
145
|
+
if len(version) > 120:
|
|
146
|
+
raise ValueError("release version is too long")
|
|
147
|
+
for required in ("execution_service.py", ".venv/bin/python"):
|
|
148
|
+
item = path / required
|
|
149
|
+
# uv's interpreter can be a symlink into its managed Python cache.
|
|
150
|
+
if not item.is_file() or (required.endswith("python") and not os.access(item, os.X_OK)):
|
|
151
|
+
raise ValueError(f"execution release is incomplete: {required}")
|
|
152
|
+
_read_file(path / "execution_service.py")
|
|
153
|
+
return version
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class ExecutionLayout:
|
|
158
|
+
install_root: Path
|
|
159
|
+
config_root: Path
|
|
160
|
+
state_root: Path
|
|
161
|
+
home: Path
|
|
162
|
+
platform: str
|
|
163
|
+
worker_release: Path
|
|
164
|
+
gateway_release: Path
|
|
165
|
+
bind: str
|
|
166
|
+
port: int
|
|
167
|
+
environment: dict[str, str] = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_dict(cls, value: dict[str, Any]) -> "ExecutionLayout":
|
|
171
|
+
expected = {"install_root", "config_root", "state_root", "home", "platform",
|
|
172
|
+
"worker_release", "gateway_release", "bind", "port", "environment"}
|
|
173
|
+
if not isinstance(value, dict) or set(value) != expected:
|
|
174
|
+
raise ValueError("execution layout fields are invalid")
|
|
175
|
+
converted = dict(value)
|
|
176
|
+
for key in ("install_root", "config_root", "state_root", "home", "worker_release", "gateway_release"):
|
|
177
|
+
converted[key] = _path(converted[key])
|
|
178
|
+
result = cls(**converted)
|
|
179
|
+
result.validate()
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
def to_dict(self) -> dict[str, Any]:
|
|
183
|
+
return {"install_root": str(self.install_root), "config_root": str(self.config_root),
|
|
184
|
+
"state_root": str(self.state_root), "home": str(self.home),
|
|
185
|
+
"platform": self.platform, "worker_release": str(self.worker_release),
|
|
186
|
+
"gateway_release": str(self.gateway_release), "bind": self.bind,
|
|
187
|
+
"port": self.port, "environment": dict(self.environment)}
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def runtime_dir(self) -> Path:
|
|
191
|
+
return self.state_root / "execution"
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def current(self) -> Path:
|
|
195
|
+
return self.install_root / "current"
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def manifest_path(self) -> Path:
|
|
199
|
+
return self.install_root / LAYOUT_NAME
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def transaction_dir(self) -> Path:
|
|
203
|
+
return self.install_root / TRANSACTION_NAME
|
|
204
|
+
|
|
205
|
+
def service_path(self, role: str) -> Path:
|
|
206
|
+
if role not in {"worker", "gateway"}:
|
|
207
|
+
raise ValueError("unknown execution service role")
|
|
208
|
+
if self.platform == "Darwin":
|
|
209
|
+
label = WORKER_LABEL if role == "worker" else GATEWAY_LABEL
|
|
210
|
+
return self.home / "Library/LaunchAgents" / f"{label}.plist"
|
|
211
|
+
unit = WORKER_UNIT if role == "worker" else GATEWAY_UNIT
|
|
212
|
+
return self.home / ".config/systemd/user" / unit
|
|
213
|
+
|
|
214
|
+
def validate(self) -> None:
|
|
215
|
+
for path in (self.install_root, self.config_root, self.state_root, self.home):
|
|
216
|
+
_path(path)
|
|
217
|
+
_owned_directory(path)
|
|
218
|
+
roots = (self.install_root, self.config_root, self.state_root)
|
|
219
|
+
for i, first in enumerate(roots):
|
|
220
|
+
for second in roots[i + 1:]:
|
|
221
|
+
if first == second or first in second.parents or second in first.parents:
|
|
222
|
+
raise ValueError("execution roots must not overlap")
|
|
223
|
+
if self.platform not in {"Darwin", "Linux"}:
|
|
224
|
+
raise ValueError("unsupported execution service platform")
|
|
225
|
+
if type(self.port) is not int or not 1 <= self.port <= 65535:
|
|
226
|
+
raise ValueError("public port must be between 1 and 65535")
|
|
227
|
+
_text(self.bind)
|
|
228
|
+
if not isinstance(self.environment, dict):
|
|
229
|
+
raise ValueError("environment must be a string mapping")
|
|
230
|
+
for key, value in self.environment.items():
|
|
231
|
+
if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
232
|
+
raise ValueError("invalid execution environment variable name")
|
|
233
|
+
if not isinstance(value, str) or any(ord(c) < 32 for c in value):
|
|
234
|
+
raise ValueError("invalid execution environment value")
|
|
235
|
+
_release(self.worker_release, self.install_root)
|
|
236
|
+
_release(self.gateway_release, self.install_root)
|
|
237
|
+
for role in ("worker", "gateway"):
|
|
238
|
+
_path(self.service_path(role))
|
|
239
|
+
parent = self.service_path(role).parent
|
|
240
|
+
while not parent.exists():
|
|
241
|
+
parent = parent.parent
|
|
242
|
+
_owned_directory(parent)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def service_arguments(layout: ExecutionLayout, role: str) -> list[str]:
|
|
246
|
+
runtime = layout.worker_release if role == "worker" else layout.current
|
|
247
|
+
args = [str(runtime / ".venv/bin/python"), str(runtime / "execution_service.py"),
|
|
248
|
+
role, "--runtime-dir", str(layout.runtime_dir), "--bind", layout.bind,
|
|
249
|
+
"--port", str(layout.port)]
|
|
250
|
+
if role == "worker":
|
|
251
|
+
args += ["--callback-port", "0"]
|
|
252
|
+
return args
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _unit_word(value: str, *, command: bool = False) -> str:
|
|
256
|
+
_text(value)
|
|
257
|
+
value = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%")
|
|
258
|
+
if command:
|
|
259
|
+
value = value.replace("$", "$$")
|
|
260
|
+
return f'"{value}"'
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _unit_directory(path: Path) -> str:
|
|
264
|
+
# WorkingDirectory consumes one entire path, not shell-style words. Quotes
|
|
265
|
+
# become literal filename bytes and make an absolute path invalid in systemd.
|
|
266
|
+
value = _text(str(path))
|
|
267
|
+
if value.strip() != value:
|
|
268
|
+
raise ValueError("systemd working directory cannot have outer whitespace")
|
|
269
|
+
return value.replace("%", "%%")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def render_service(layout: ExecutionLayout, role: str) -> bytes:
|
|
273
|
+
layout.validate()
|
|
274
|
+
if role not in {"worker", "gateway"}:
|
|
275
|
+
raise ValueError("unknown execution service role")
|
|
276
|
+
runtime = layout.worker_release if role == "worker" else layout.current
|
|
277
|
+
environment = dict(layout.environment)
|
|
278
|
+
authoritative = {"AGENTS_SERVER_INSTALL_DIR": str(layout.install_root),
|
|
279
|
+
"AGENTS_SERVER_CONFIG_DIR": str(layout.config_root),
|
|
280
|
+
"AGENTSDOCK_STATE_DIR": str(layout.state_root),
|
|
281
|
+
"AGENTS_SERVER_STATE_DIR": str(layout.state_root),
|
|
282
|
+
"AGENTSDOCK_AGENT_BIND": layout.bind,
|
|
283
|
+
"AGENTSDOCK_AGENT_PORT": str(layout.port),
|
|
284
|
+
"AGENTSDOCK_EXECUTION_ROLE": role,
|
|
285
|
+
"AGENTSDOCK_EXECUTION_RUNTIME_DIR": str(layout.runtime_dir)}
|
|
286
|
+
environment.update(authoritative)
|
|
287
|
+
if layout.platform == "Darwin":
|
|
288
|
+
return plistlib.dumps({
|
|
289
|
+
"Label": WORKER_LABEL if role == "worker" else GATEWAY_LABEL,
|
|
290
|
+
"ProgramArguments": service_arguments(layout, role),
|
|
291
|
+
"WorkingDirectory": str(runtime), "EnvironmentVariables": environment,
|
|
292
|
+
"RunAtLoad": True, "KeepAlive": True,
|
|
293
|
+
# launchd interprets zero as no automatic SIGKILL deadline. The
|
|
294
|
+
# controller reports a bounded failure while keeping the journal.
|
|
295
|
+
"ExitTimeOut": 0 if role == "worker" else 10,
|
|
296
|
+
"Umask": 0o077,
|
|
297
|
+
"StandardOutPath": str(layout.runtime_dir / "logs" / f"{role}.stdout.log"),
|
|
298
|
+
"StandardErrorPath": str(layout.runtime_dir / "logs" / f"{role}.stderr.log"),
|
|
299
|
+
}, sort_keys=True)
|
|
300
|
+
lines = ["[Unit]", f"Description=AgentsDock {role}", "After=network-online.target"]
|
|
301
|
+
if role == "gateway":
|
|
302
|
+
# Wants starts a missing worker without propagating gateway stop/restart.
|
|
303
|
+
lines += [f"Wants={WORKER_UNIT}", f"After={WORKER_UNIT}"]
|
|
304
|
+
lines += ["", "[Service]", "Type=simple", f"WorkingDirectory={_unit_directory(runtime)}",
|
|
305
|
+
f"EnvironmentFile={_unit_word(str(layout.config_root / 'env'))}"]
|
|
306
|
+
for key, value in sorted(environment.items()):
|
|
307
|
+
lines.append(f"Environment={_unit_word(key + '=' + value)}")
|
|
308
|
+
# systemd EnvironmentFile takes precedence over Environment. Reassert only
|
|
309
|
+
# layout authority with exec(1), preserving file-loaded custom provider vars.
|
|
310
|
+
# env immediately execs the pinned Python; there is no shell or extra owner.
|
|
311
|
+
command = ["/usr/bin/env", *[f"{key}={value}" for key, value in sorted(authoritative.items())],
|
|
312
|
+
*service_arguments(layout, role)]
|
|
313
|
+
lines += ["ExecStart=" + " ".join(_unit_word(arg, command=True) for arg in command),
|
|
314
|
+
"Restart=always", "RestartSec=2",
|
|
315
|
+
"TimeoutStopSec=180s" if role == "worker" else "TimeoutStopSec=10s",
|
|
316
|
+
*(["SendSIGKILL=no"] if role == "worker" else []), "UMask=0077",
|
|
317
|
+
"", "[Install]", "WantedBy=default.target", ""]
|
|
318
|
+
return "\n".join(lines).encode()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def layout_manifest(layout: ExecutionLayout) -> dict[str, Any]:
|
|
322
|
+
layout.validate()
|
|
323
|
+
return {"format": FORMAT, "protocol_version": PROTOCOL_VERSION, "layout": layout.to_dict(),
|
|
324
|
+
"worker_version": _release(layout.worker_release, layout.install_root),
|
|
325
|
+
"gateway_version": _release(layout.gateway_release, layout.install_root),
|
|
326
|
+
"runtime_dir": str(layout.runtime_dir), "socket_path": str(layout.runtime_dir / "worker.socket"),
|
|
327
|
+
"token_path": str(layout.runtime_dir / "control.token"),
|
|
328
|
+
"callback": {"bind": "127.0.0.1", "port": 0, "lifetime": "worker-process"},
|
|
329
|
+
"retained_releases": sorted({str(layout.worker_release), str(layout.gateway_release)})}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def prepare_runtime(layout: ExecutionLayout) -> None:
|
|
333
|
+
"""Create private IPC state; never rotate an existing control token."""
|
|
334
|
+
from execution_transport import ensure_execution_secret
|
|
335
|
+
|
|
336
|
+
layout.validate()
|
|
337
|
+
_ensure_directory(layout.runtime_dir, private=True)
|
|
338
|
+
_ensure_directory(layout.runtime_dir / "logs", private=True)
|
|
339
|
+
ensure_execution_secret(layout.runtime_dir / "control.token")
|
|
340
|
+
_fsync_directory(layout.runtime_dir)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _snapshot(path: Path) -> dict[str, Any]:
|
|
344
|
+
try:
|
|
345
|
+
data, mode = _read_file(path)
|
|
346
|
+
except FileNotFoundError:
|
|
347
|
+
return {"exists": False}
|
|
348
|
+
return {"exists": True, "mode": mode, "sha256": hashlib.sha256(data).hexdigest(),
|
|
349
|
+
"data": base64.b64encode(data).decode()}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _desired(data: bytes) -> dict[str, Any]:
|
|
353
|
+
return {"exists": True, "mode": 0o600, "sha256": hashlib.sha256(data).hexdigest(),
|
|
354
|
+
"data": base64.b64encode(data).decode()}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _current_target(layout: ExecutionLayout) -> str | None:
|
|
358
|
+
try:
|
|
359
|
+
info = layout.current.lstat()
|
|
360
|
+
except FileNotFoundError:
|
|
361
|
+
return None
|
|
362
|
+
if not stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid():
|
|
363
|
+
raise PermissionError("execution current must be an owned release symlink")
|
|
364
|
+
target = layout.current.resolve(strict=True)
|
|
365
|
+
if target.parent != layout.install_root / "releases":
|
|
366
|
+
raise PermissionError("execution current must point into retained releases")
|
|
367
|
+
_owned_directory(target)
|
|
368
|
+
return os.readlink(layout.current)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _service_states(value: Any) -> dict[str, Any]:
|
|
372
|
+
if not isinstance(value, dict) or set(value) != {"worker", "gateway"}:
|
|
373
|
+
raise ValueError("both prior native service states are required")
|
|
374
|
+
for item in value.values():
|
|
375
|
+
if (not isinstance(item, dict) or not {"state", "enabled"} <= set(item)
|
|
376
|
+
or not set(item) <= {"state", "enabled", "pid", "instance_id"}
|
|
377
|
+
or item["state"] not in {"absent", "stopped", "running"}
|
|
378
|
+
or type(item["enabled"]) is not bool
|
|
379
|
+
or (item["state"] == "absent" and item["enabled"])):
|
|
380
|
+
raise ValueError("invalid prior native service state")
|
|
381
|
+
if "pid" in item and (type(item["pid"]) is not int or item["pid"] <= 0):
|
|
382
|
+
raise ValueError("invalid native service process identity")
|
|
383
|
+
if "instance_id" in item:
|
|
384
|
+
_text(item["instance_id"])
|
|
385
|
+
return value
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def stage(layout: ExecutionLayout, *, scope: str, prior_services: dict[str, Any],
|
|
389
|
+
expected_server_identity: str | None = None) -> dict[str, Any]:
|
|
390
|
+
layout.validate()
|
|
391
|
+
if expected_server_identity is not None:
|
|
392
|
+
_text(expected_server_identity)
|
|
393
|
+
if scope not in {"migration", "gateway", "worker"}:
|
|
394
|
+
raise ValueError("unknown execution activation scope")
|
|
395
|
+
_service_states(prior_services)
|
|
396
|
+
if scope == "gateway" and prior_services["worker"]["state"] == "running":
|
|
397
|
+
if not {"pid", "instance_id"} <= set(prior_services["worker"]):
|
|
398
|
+
raise ValueError("gateway activation requires the retained worker's process identity")
|
|
399
|
+
legacy = layout.install_root / ".activation-transaction"
|
|
400
|
+
if legacy.exists() or legacy.is_symlink():
|
|
401
|
+
raise RuntimeError("legacy activation recovery must finish before execution activation")
|
|
402
|
+
if layout.transaction_dir.exists() or layout.transaction_dir.is_symlink():
|
|
403
|
+
raise FileExistsError("an execution activation is already pending")
|
|
404
|
+
prior_layout = _snapshot(layout.manifest_path)
|
|
405
|
+
if scope == "migration":
|
|
406
|
+
if prior_layout["exists"]:
|
|
407
|
+
raise ValueError("execution layout already exists; migration is not an update")
|
|
408
|
+
else:
|
|
409
|
+
if not prior_layout["exists"]:
|
|
410
|
+
raise ValueError("execution activation requires a previous layout")
|
|
411
|
+
previous = json.loads(base64.b64decode(prior_layout["data"]))
|
|
412
|
+
old = ExecutionLayout.from_dict(previous["layout"])
|
|
413
|
+
changed = {k for k, v in old.to_dict().items() if layout.to_dict()[k] != v}
|
|
414
|
+
allowed = {"gateway_release"} if scope == "gateway" else {"worker_release"}
|
|
415
|
+
if not changed <= allowed:
|
|
416
|
+
raise ValueError(f"{scope} activation changes another component's configuration")
|
|
417
|
+
role = "worker" if scope == "gateway" else "gateway"
|
|
418
|
+
if _snapshot(layout.service_path(role)) != _desired(render_service(old, role)):
|
|
419
|
+
raise RuntimeError("retained service configuration no longer matches its layout")
|
|
420
|
+
paths = {role: layout.service_path(role) for role in ("worker", "gateway")}
|
|
421
|
+
paths["layout"] = layout.manifest_path
|
|
422
|
+
snapshots = {key: _snapshot(path) for key, path in paths.items()}
|
|
423
|
+
for role in ("worker", "gateway"):
|
|
424
|
+
if not snapshots[role]["exists"] and prior_services[role]["state"] != "absent":
|
|
425
|
+
raise ValueError("loaded native service has no restorable configuration")
|
|
426
|
+
current = _current_target(layout)
|
|
427
|
+
value = {"format": FORMAT, "id": "execution-" + secrets.token_hex(12), "scope": scope,
|
|
428
|
+
"phase": "prepared", "layout": layout.to_dict(), "prior_services": prior_services,
|
|
429
|
+
"paths": {key: str(path) for key, path in paths.items()}, "before": snapshots,
|
|
430
|
+
"after": {"worker": _desired(render_service(layout, "worker")),
|
|
431
|
+
"gateway": _desired(render_service(layout, "gateway")),
|
|
432
|
+
"layout": _desired(_json_bytes(layout_manifest(layout)))},
|
|
433
|
+
"current_before": current, "current_after": str(layout.gateway_release),
|
|
434
|
+
"expected_server_identity": expected_server_identity,
|
|
435
|
+
"receipt": None}
|
|
436
|
+
# Publish a complete journal before any configuration change. An interruption
|
|
437
|
+
# during preparation leaves only an unreferenced private draft, never an
|
|
438
|
+
# empty pending journal that blocks a safe retry. The caller owns install-lock.
|
|
439
|
+
draft = layout.install_root / (".execution-draft-" + value["id"])
|
|
440
|
+
draft.mkdir(mode=0o700)
|
|
441
|
+
_atomic_write(draft / "manifest.json", _json_bytes(value))
|
|
442
|
+
if layout.transaction_dir.exists() or layout.transaction_dir.is_symlink():
|
|
443
|
+
raise FileExistsError("an execution activation is already pending")
|
|
444
|
+
os.rename(draft, layout.transaction_dir)
|
|
445
|
+
_fsync_directory(layout.install_root)
|
|
446
|
+
return value
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _load(root: Path) -> tuple[dict[str, Any], ExecutionLayout]:
|
|
450
|
+
root = _path(root)
|
|
451
|
+
_owned_directory(root)
|
|
452
|
+
directory = root / TRANSACTION_NAME
|
|
453
|
+
_owned_directory(directory, private=True)
|
|
454
|
+
data, _mode = _read_file(directory / "manifest.json", private=True)
|
|
455
|
+
value = json.loads(data)
|
|
456
|
+
required = {"format", "id", "scope", "phase", "layout", "prior_services", "paths",
|
|
457
|
+
"before", "after", "current_before", "current_after", "receipt", "expected_server_identity"}
|
|
458
|
+
if not isinstance(value, dict) or set(value) != required or value["format"] != FORMAT:
|
|
459
|
+
raise ValueError("invalid execution transaction")
|
|
460
|
+
maintenance_operation_id(value)
|
|
461
|
+
if value["expected_server_identity"] is not None:
|
|
462
|
+
_text(value["expected_server_identity"])
|
|
463
|
+
layout = ExecutionLayout.from_dict(value["layout"])
|
|
464
|
+
if layout.install_root != root:
|
|
465
|
+
raise ValueError("execution transaction belongs to another install root")
|
|
466
|
+
if value["scope"] not in {"migration", "gateway", "worker"} or value["phase"] not in {
|
|
467
|
+
"prepared", "publishing", "published", "committed", "rolling-back", "rolled-back"}:
|
|
468
|
+
raise ValueError("invalid execution transaction state")
|
|
469
|
+
_service_states(value["prior_services"])
|
|
470
|
+
expected_paths = {role: str(layout.service_path(role)) for role in ("worker", "gateway")}
|
|
471
|
+
expected_paths["layout"] = str(layout.manifest_path)
|
|
472
|
+
if value["paths"] != expected_paths or value["current_after"] != str(layout.gateway_release):
|
|
473
|
+
raise ValueError("execution transaction paths changed")
|
|
474
|
+
expected_after = {"worker": _desired(render_service(layout, "worker")),
|
|
475
|
+
"gateway": _desired(render_service(layout, "gateway")),
|
|
476
|
+
"layout": _desired(_json_bytes(layout_manifest(layout)))}
|
|
477
|
+
if value["after"] != expected_after or set(value["before"]) != set(expected_paths):
|
|
478
|
+
raise ValueError("execution transaction content changed")
|
|
479
|
+
for item in value["before"].values():
|
|
480
|
+
if item == {"exists": False}:
|
|
481
|
+
continue
|
|
482
|
+
if not isinstance(item, dict) or set(item) != {"exists", "mode", "sha256", "data"}:
|
|
483
|
+
raise ValueError("invalid execution snapshot")
|
|
484
|
+
content = base64.b64decode(item["data"], validate=True)
|
|
485
|
+
if (item["exists"] is not True or type(item["mode"]) is not int
|
|
486
|
+
or not 0 <= item["mode"] <= 0o777 or item["mode"] & 0o022
|
|
487
|
+
or len(content) > MAX_CONFIG_BYTES
|
|
488
|
+
or hashlib.sha256(content).hexdigest() != item["sha256"]):
|
|
489
|
+
raise ValueError("invalid execution snapshot content")
|
|
490
|
+
before = value["current_before"]
|
|
491
|
+
if before is not None:
|
|
492
|
+
target = Path(before)
|
|
493
|
+
if not target.is_absolute():
|
|
494
|
+
target = root / target
|
|
495
|
+
if target.resolve(strict=True).parent != root / "releases":
|
|
496
|
+
raise ValueError("previous execution current escaped release root")
|
|
497
|
+
return value, layout
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _save(value: dict[str, Any], layout: ExecutionLayout) -> None:
|
|
501
|
+
_atomic_write(layout.transaction_dir / "manifest.json", _json_bytes(value))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _affected(value: dict[str, Any]) -> tuple[str, ...]:
|
|
505
|
+
if value["scope"] == "gateway":
|
|
506
|
+
return "gateway", "layout"
|
|
507
|
+
if value["scope"] == "worker":
|
|
508
|
+
return "worker", "layout"
|
|
509
|
+
return "worker", "gateway", "layout"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _check_publication(value: dict[str, Any], layout: ExecutionLayout) -> None:
|
|
513
|
+
for key in ("worker", "gateway", "layout"):
|
|
514
|
+
actual = _snapshot(Path(value["paths"][key]))
|
|
515
|
+
accepted = [value["before"][key]]
|
|
516
|
+
if key in _affected(value):
|
|
517
|
+
accepted.append(value["after"][key])
|
|
518
|
+
if actual not in accepted:
|
|
519
|
+
raise RuntimeError(f"{key} configuration changed outside execution activation")
|
|
520
|
+
if _current_target(layout) not in (value["current_before"], value["current_after"]):
|
|
521
|
+
raise RuntimeError("gateway current changed outside execution activation")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _replace_link(layout: ExecutionLayout, target: str | None) -> None:
|
|
525
|
+
if _current_target(layout) == target:
|
|
526
|
+
return
|
|
527
|
+
if target is None:
|
|
528
|
+
layout.current.unlink()
|
|
529
|
+
else:
|
|
530
|
+
temporary = layout.install_root / (".execution-current-" + secrets.token_hex(12))
|
|
531
|
+
temporary.symlink_to(target)
|
|
532
|
+
os.replace(temporary, layout.current)
|
|
533
|
+
_fsync_directory(layout.install_root)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def publish(root: Path) -> dict[str, Any]:
|
|
537
|
+
value, layout = _load(root)
|
|
538
|
+
if value["phase"] not in {"prepared", "publishing", "published"}:
|
|
539
|
+
raise ValueError("execution activation cannot publish in this phase")
|
|
540
|
+
_check_publication(value, layout)
|
|
541
|
+
value["phase"] = "publishing"
|
|
542
|
+
_save(value, layout)
|
|
543
|
+
for key in _affected(value):
|
|
544
|
+
item = value["after"][key]
|
|
545
|
+
if _snapshot(Path(value["paths"][key])) != item:
|
|
546
|
+
_atomic_write(Path(value["paths"][key]), base64.b64decode(item["data"]), item["mode"])
|
|
547
|
+
if value["scope"] != "worker":
|
|
548
|
+
_replace_link(layout, value["current_after"])
|
|
549
|
+
value["phase"] = "published"
|
|
550
|
+
_save(value, layout)
|
|
551
|
+
return value
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def rollback(root: Path) -> dict[str, Any]:
|
|
555
|
+
value, layout = _load(root)
|
|
556
|
+
if value["phase"] == "committed":
|
|
557
|
+
raise ValueError("committed execution activation cannot roll back")
|
|
558
|
+
_check_publication(value, layout)
|
|
559
|
+
value["phase"] = "rolling-back"
|
|
560
|
+
_save(value, layout)
|
|
561
|
+
for key in _affected(value):
|
|
562
|
+
path, item = Path(value["paths"][key]), value["before"][key]
|
|
563
|
+
if _snapshot(path) == item:
|
|
564
|
+
continue
|
|
565
|
+
if item["exists"]:
|
|
566
|
+
_atomic_write(path, base64.b64decode(item["data"]), item["mode"])
|
|
567
|
+
else:
|
|
568
|
+
path.unlink()
|
|
569
|
+
_fsync_directory(path.parent)
|
|
570
|
+
if value["scope"] != "worker":
|
|
571
|
+
_replace_link(layout, value["current_before"])
|
|
572
|
+
value["phase"] = "rolled-back"
|
|
573
|
+
_save(value, layout)
|
|
574
|
+
return value
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def commit(root: Path, receipt: dict[str, Any]) -> dict[str, Any]:
|
|
578
|
+
"""Record caller-verified component health, never infer health from files."""
|
|
579
|
+
value, layout = _load(root)
|
|
580
|
+
if value["phase"] != "published":
|
|
581
|
+
raise ValueError("execution activation has not been published")
|
|
582
|
+
_check_publication(value, layout)
|
|
583
|
+
for key in _affected(value):
|
|
584
|
+
if _snapshot(Path(value["paths"][key])) != value["after"][key]:
|
|
585
|
+
raise RuntimeError("execution publication is incomplete")
|
|
586
|
+
if _current_target(layout) != value["current_after"]:
|
|
587
|
+
raise RuntimeError("gateway release link is not the candidate")
|
|
588
|
+
expected = layout_manifest(layout)
|
|
589
|
+
if (not isinstance(receipt, dict)
|
|
590
|
+
or receipt.get("gateway_version") != expected["gateway_version"]
|
|
591
|
+
or receipt.get("worker_version") != expected["worker_version"]
|
|
592
|
+
or receipt.get("worker_release") != str(layout.worker_release)
|
|
593
|
+
or receipt.get("protocol_version") != PROTOCOL_VERSION
|
|
594
|
+
or not isinstance(receipt.get("server_identity"), str)
|
|
595
|
+
or not receipt["server_identity"]
|
|
596
|
+
or any(type(receipt.get(key)) is not int or receipt[key] <= 0
|
|
597
|
+
for key in ("gateway_pid", "worker_pid"))):
|
|
598
|
+
raise ValueError("authenticated component health does not match the activation")
|
|
599
|
+
retained_worker = value["prior_services"]["worker"]
|
|
600
|
+
if (value["expected_server_identity"] is not None
|
|
601
|
+
and receipt["server_identity"] != value["expected_server_identity"]):
|
|
602
|
+
raise ValueError("activation changed the authenticated server identity")
|
|
603
|
+
if value["scope"] == "gateway" and retained_worker["state"] == "running":
|
|
604
|
+
if (receipt["worker_pid"] != retained_worker["pid"]
|
|
605
|
+
or receipt.get("worker_instance_id") != retained_worker["instance_id"]):
|
|
606
|
+
raise ValueError("gateway activation did not preserve the exact worker process")
|
|
607
|
+
value["receipt"] = receipt
|
|
608
|
+
value["phase"] = "committed"
|
|
609
|
+
_save(value, layout)
|
|
610
|
+
return value
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def finish(root: Path) -> None:
|
|
614
|
+
"""Retire only a completed journal; never garbage-collect any runtime."""
|
|
615
|
+
value, layout = _load(root)
|
|
616
|
+
if value["phase"] not in {"committed", "rolled-back"}:
|
|
617
|
+
raise ValueError("execution activation is not finished")
|
|
618
|
+
_check_publication(value, layout)
|
|
619
|
+
side = "after" if value["phase"] == "committed" else "before"
|
|
620
|
+
for key in ("worker", "gateway", "layout"):
|
|
621
|
+
expected = value[side][key] if key in _affected(value) else value["before"][key]
|
|
622
|
+
if _snapshot(Path(value["paths"][key])) != expected:
|
|
623
|
+
raise RuntimeError("execution terminal configuration no longer matches its receipt")
|
|
624
|
+
if _current_target(layout) != value["current_" + side]:
|
|
625
|
+
raise RuntimeError("execution terminal release no longer matches its receipt")
|
|
626
|
+
if set(path.name for path in layout.transaction_dir.iterdir()) != {"manifest.json"}:
|
|
627
|
+
raise RuntimeError("execution transaction contains unexpected files")
|
|
628
|
+
retired = root / (".execution-completed-" + value["id"])
|
|
629
|
+
os.rename(layout.transaction_dir, retired)
|
|
630
|
+
_fsync_directory(root)
|
|
631
|
+
# Keep the compact receipt as audit/recovery evidence; no active lease is lost.
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def retained_releases(root: Path) -> list[str]:
|
|
635
|
+
"""Return all releases the active layout or an unfinished journal can need."""
|
|
636
|
+
root = _path(root)
|
|
637
|
+
_owned_directory(root)
|
|
638
|
+
retained: set[str] = set()
|
|
639
|
+
outer = root / ".activation-transaction"
|
|
640
|
+
if outer.exists() or outer.is_symlink():
|
|
641
|
+
from activation_transaction import execution_context
|
|
642
|
+
pending = execution_context(root)
|
|
643
|
+
if pending is not None:
|
|
644
|
+
for release in (pending["candidate_release"], pending["old_release"],
|
|
645
|
+
pending["execution"]["old_worker_release"]):
|
|
646
|
+
if release:
|
|
647
|
+
retained.update((release["source"], release["target"]))
|
|
648
|
+
try:
|
|
649
|
+
data, _mode = _read_file(root / LAYOUT_NAME, private=True)
|
|
650
|
+
except FileNotFoundError:
|
|
651
|
+
pass
|
|
652
|
+
else:
|
|
653
|
+
layout = ExecutionLayout.from_dict(json.loads(data)["layout"])
|
|
654
|
+
if layout.install_root != root:
|
|
655
|
+
raise ValueError("execution manifest belongs to another installation")
|
|
656
|
+
retained.update((str(layout.worker_release), str(layout.gateway_release)))
|
|
657
|
+
if (root / TRANSACTION_NAME).exists():
|
|
658
|
+
value, layout = _load(root)
|
|
659
|
+
retained.update((str(layout.worker_release), str(layout.gateway_release)))
|
|
660
|
+
if value["current_before"]:
|
|
661
|
+
target = Path(value["current_before"])
|
|
662
|
+
retained.add(str((target if target.is_absolute() else root / target).resolve(strict=True)))
|
|
663
|
+
previous = value["before"]["layout"]
|
|
664
|
+
if previous["exists"]:
|
|
665
|
+
old = ExecutionLayout.from_dict(json.loads(base64.b64decode(previous["data"]))["layout"])
|
|
666
|
+
retained.update((str(old.worker_release), str(old.gateway_release)))
|
|
667
|
+
return sorted(retained)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def pending_worker_operation(root: Path, runtime_root: Path) -> str | None:
|
|
671
|
+
"""Bind startup admission to either worker generation until finalization."""
|
|
672
|
+
root, runtime_root = _path(root), _path(runtime_root)
|
|
673
|
+
_owned_directory(root)
|
|
674
|
+
journal = root / TRANSACTION_NAME
|
|
675
|
+
outer = root / ".activation-transaction"
|
|
676
|
+
uninstall = root / ".execution-uninstall.json"
|
|
677
|
+
if uninstall.exists() or uninstall.is_symlink():
|
|
678
|
+
if outer.exists() or outer.is_symlink() or journal.exists() or journal.is_symlink():
|
|
679
|
+
raise RuntimeError("activation and uninstall both claim execution ownership")
|
|
680
|
+
from execution_uninstall import pending_uninstall_operation
|
|
681
|
+
return pending_uninstall_operation(root, runtime_root)
|
|
682
|
+
if outer.exists() or outer.is_symlink():
|
|
683
|
+
if journal.exists() or journal.is_symlink():
|
|
684
|
+
raise RuntimeError("two activation journals claim execution ownership")
|
|
685
|
+
from activation_transaction import pending_execution_worker_operation
|
|
686
|
+
return pending_execution_worker_operation(root, runtime_root)
|
|
687
|
+
if not journal.exists() and not journal.is_symlink():
|
|
688
|
+
return None
|
|
689
|
+
value, layout = _load(root)
|
|
690
|
+
if value["scope"] == "gateway":
|
|
691
|
+
return None
|
|
692
|
+
retained = {layout.worker_release}
|
|
693
|
+
previous = value["before"]["layout"]
|
|
694
|
+
if previous["exists"]:
|
|
695
|
+
old = ExecutionLayout.from_dict(json.loads(base64.b64decode(previous["data"]))["layout"])
|
|
696
|
+
if old.install_root != root:
|
|
697
|
+
raise ValueError("previous execution layout belongs to another installation")
|
|
698
|
+
retained.add(old.worker_release)
|
|
699
|
+
elif value["current_before"]:
|
|
700
|
+
target = Path(value["current_before"])
|
|
701
|
+
retained.add((target if target.is_absolute() else root / target).resolve(strict=True))
|
|
702
|
+
if runtime_root not in retained:
|
|
703
|
+
raise RuntimeError("worker runtime is outside the pending activation generations")
|
|
704
|
+
_owned_directory(runtime_root)
|
|
705
|
+
_read_file(runtime_root / "VERSION")
|
|
706
|
+
return maintenance_operation_id(value)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def active_worker_release(root: Path) -> Path | None:
|
|
710
|
+
"""Resolve a validated installed worker pin without following gateway current."""
|
|
711
|
+
root = _path(root)
|
|
712
|
+
_owned_directory(root)
|
|
713
|
+
try:
|
|
714
|
+
data, _mode = _read_file(root / LAYOUT_NAME, private=True)
|
|
715
|
+
except FileNotFoundError:
|
|
716
|
+
return None
|
|
717
|
+
value = json.loads(data)
|
|
718
|
+
if not isinstance(value, dict) or value.get("format") != FORMAT:
|
|
719
|
+
raise ValueError("installed execution layout is invalid")
|
|
720
|
+
layout = ExecutionLayout.from_dict(value.get("layout"))
|
|
721
|
+
if layout.install_root != root or value != layout_manifest(layout):
|
|
722
|
+
raise ValueError("installed execution layout no longer matches its runtime")
|
|
723
|
+
return layout.worker_release
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def main(argv: list[str] | None = None) -> None:
|
|
727
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
728
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
729
|
+
for command in ("validate", "manifest", "prepare-runtime", "render", "stage"):
|
|
730
|
+
sub = commands.add_parser(command)
|
|
731
|
+
sub.add_argument("--layout", type=Path, required=True)
|
|
732
|
+
if command == "render":
|
|
733
|
+
sub.add_argument("--role", choices=("worker", "gateway"), required=True)
|
|
734
|
+
if command in {"render", "manifest"}:
|
|
735
|
+
sub.add_argument("--output", type=Path, required=True)
|
|
736
|
+
if command == "stage":
|
|
737
|
+
sub.add_argument("--scope", choices=("migration", "gateway", "worker"), required=True)
|
|
738
|
+
sub.add_argument("--prior-services", type=Path, required=True)
|
|
739
|
+
for command in ("publish", "rollback", "commit", "finish", "retained", "inspect"):
|
|
740
|
+
sub = commands.add_parser(command)
|
|
741
|
+
sub.add_argument("--root", type=Path, required=True)
|
|
742
|
+
if command == "commit":
|
|
743
|
+
sub.add_argument("--health-receipt", type=Path, required=True)
|
|
744
|
+
if command == "inspect":
|
|
745
|
+
sub.add_argument("--output", type=Path, required=True)
|
|
746
|
+
args = parser.parse_args(argv)
|
|
747
|
+
if hasattr(args, "layout"):
|
|
748
|
+
data, _mode = _read_file(args.layout, private=True)
|
|
749
|
+
layout = ExecutionLayout.from_dict(json.loads(data))
|
|
750
|
+
if args.command == "validate":
|
|
751
|
+
layout.validate()
|
|
752
|
+
return
|
|
753
|
+
if args.command == "render":
|
|
754
|
+
_write_cli_output(args.output, render_service(layout, args.role))
|
|
755
|
+
print(json.dumps({"ok": True, "operation": args.command}))
|
|
756
|
+
return
|
|
757
|
+
if args.command == "prepare-runtime":
|
|
758
|
+
prepare_runtime(layout)
|
|
759
|
+
return
|
|
760
|
+
if args.command == "stage":
|
|
761
|
+
states, _mode = _read_file(args.prior_services, private=True)
|
|
762
|
+
result = stage(layout, scope=args.scope, prior_services=json.loads(states))
|
|
763
|
+
else:
|
|
764
|
+
result = layout_manifest(layout)
|
|
765
|
+
elif args.command == "commit":
|
|
766
|
+
data, _mode = _read_file(args.health_receipt, private=True)
|
|
767
|
+
result = commit(args.root, json.loads(data))
|
|
768
|
+
elif args.command == "inspect":
|
|
769
|
+
result, _layout = _load(args.root)
|
|
770
|
+
else:
|
|
771
|
+
operation = {"publish": publish, "rollback": rollback, "finish": finish,
|
|
772
|
+
"retained": retained_releases}[args.command]
|
|
773
|
+
result = operation(args.root)
|
|
774
|
+
if hasattr(args, "output"):
|
|
775
|
+
_write_cli_output(args.output, _json_bytes(result))
|
|
776
|
+
if isinstance(result, dict):
|
|
777
|
+
result = {key: result[key] for key in ("id", "scope", "phase") if key in result}
|
|
778
|
+
elif args.command != "retained":
|
|
779
|
+
result = None
|
|
780
|
+
print(json.dumps({"ok": True, "operation": args.command, "result": result}, sort_keys=True))
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _write_cli_output(path: Path, data: bytes) -> None:
|
|
784
|
+
"""Private detailed output is opt-in and never sent to installer logs."""
|
|
785
|
+
path = _path(path)
|
|
786
|
+
if path.exists():
|
|
787
|
+
_read_file(path, private=True)
|
|
788
|
+
_atomic_write(path, data)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
if __name__ == "__main__":
|
|
792
|
+
main()
|