@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,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()