@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,406 @@
|
|
|
1
|
+
"""Idle-only uninstall of a healthy, independently managed execution worker.
|
|
2
|
+
|
|
3
|
+
Unjournaled stopped or broken installations require separate ownership recovery.
|
|
4
|
+
A retained uninstall intent can resume only after its ownership is revalidated.
|
|
5
|
+
Pending updates/activation, live agents, changed native jobs, or a failed native
|
|
6
|
+
stop preserve all runtime and data files. State is retained unless the operator
|
|
7
|
+
separately confirms its exact path at an interactive terminal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
from contextlib import ExitStack, contextmanager
|
|
14
|
+
import fcntl
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import stat
|
|
22
|
+
import sys
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
import execution_install as files
|
|
26
|
+
from execution_manage import InstallationLock, NativeServices, WorkerControl
|
|
27
|
+
from update_handoff import _installed_layout, _no_activation
|
|
28
|
+
from execution_preparation import _binding, _check_binding
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
INTENT_NAME = ".execution-uninstall.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_intent(root: Path, layout: files.ExecutionLayout) -> dict | None:
|
|
35
|
+
try:
|
|
36
|
+
data, _mode = files._read_file(root / INTENT_NAME, private=True)
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return None
|
|
39
|
+
value = json.loads(data)
|
|
40
|
+
expected = {"format", "root", "worker_release", "gateway_release", "operation_id",
|
|
41
|
+
"purge_state", "identities", "layout_sha256", "phase", "server_identity"}
|
|
42
|
+
if (not isinstance(value, dict) or set(value) != expected or type(value["format"]) is not int
|
|
43
|
+
or value["format"] != 1 or type(value["purge_state"]) is not bool
|
|
44
|
+
or value["root"] != str(root) or value["worker_release"] != str(layout.worker_release)
|
|
45
|
+
or value["gateway_release"] != str(layout.gateway_release)
|
|
46
|
+
or value["phase"] not in {"prepared", "sealed", "stopped"}
|
|
47
|
+
or not isinstance(value["server_identity"], str)
|
|
48
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value["server_identity"]) is None
|
|
49
|
+
or not isinstance(value["operation_id"], str)
|
|
50
|
+
or str(uuid.UUID(value["operation_id"])) != value["operation_id"]):
|
|
51
|
+
raise ValueError("uninstall intent does not match this installed runtime")
|
|
52
|
+
identities = value["identities"]
|
|
53
|
+
if not isinstance(identities, dict) or set(identities) != {"root", "worker", "gateway"}:
|
|
54
|
+
raise ValueError("uninstall intent identities are invalid")
|
|
55
|
+
for name, path in (("root", root), ("worker", layout.worker_release), ("gateway", layout.gateway_release)):
|
|
56
|
+
_check_binding(path, identities[name])
|
|
57
|
+
manifest, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
|
|
58
|
+
if hashlib.sha256(manifest).hexdigest() != value["layout_sha256"]:
|
|
59
|
+
raise RuntimeError("installed execution layout changed after uninstall admission")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pending_uninstall_operation(root: Path, runtime_root: Path) -> str:
|
|
64
|
+
"""Startup-only reader: hold this exact retained worker without mutations."""
|
|
65
|
+
_no_activation(root)
|
|
66
|
+
layout = _installed_layout(root)
|
|
67
|
+
value = _read_intent(root, layout)
|
|
68
|
+
if value is None or files._path(runtime_root) != layout.worker_release:
|
|
69
|
+
raise RuntimeError("worker is not retained by the pending uninstall")
|
|
70
|
+
return value["operation_id"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_intent(root: Path, layout: files.ExecutionLayout, operation: str, purge_state: bool,
|
|
74
|
+
server_identity: str) -> dict:
|
|
75
|
+
if (root / INTENT_NAME).exists() or (root / INTENT_NAME).is_symlink():
|
|
76
|
+
raise RuntimeError("another uninstall intent already exists")
|
|
77
|
+
manifest, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
|
|
78
|
+
value = {"format": 1, "root": str(root), "worker_release": str(layout.worker_release),
|
|
79
|
+
"gateway_release": str(layout.gateway_release), "operation_id": operation,
|
|
80
|
+
"server_identity": server_identity,
|
|
81
|
+
"purge_state": purge_state, "phase": "prepared", "layout_sha256": hashlib.sha256(manifest).hexdigest(),
|
|
82
|
+
"identities": {"root": _binding(root), "worker": _binding(layout.worker_release),
|
|
83
|
+
"gateway": _binding(layout.gateway_release)}}
|
|
84
|
+
files._atomic_write(root / INTENT_NAME, files._json_bytes(value))
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _no_provider_children(state_root: Path) -> None:
|
|
89
|
+
try:
|
|
90
|
+
data, _mode = files._read_file(state_root / "admin/provider-children.json", private=True)
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
return
|
|
93
|
+
value = json.loads(data)
|
|
94
|
+
if not isinstance(value, dict) or value.get("children") != []:
|
|
95
|
+
raise RuntimeError("provider child ownership remains unresolved; runtime files were retained")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _identity(path: Path) -> tuple[int, int]:
|
|
99
|
+
info = path.lstat()
|
|
100
|
+
return info.st_dev, info.st_ino
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _no_pending_update(layout: files.ExecutionLayout) -> None:
|
|
104
|
+
path = layout.state_root / "admin/server-update.json"
|
|
105
|
+
try:
|
|
106
|
+
data, _mode = files._read_file(path, private=True)
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
return
|
|
109
|
+
value = json.loads(data)
|
|
110
|
+
if (not isinstance(value, dict) or value.get("phase") not in {
|
|
111
|
+
"idle", "available", "current", "complete", "failed", "cancelled", "canceled"}):
|
|
112
|
+
raise RuntimeError("an update or prepared candidate is pending; cancel or finish it before uninstalling")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def _owned_lease(path: Path):
|
|
117
|
+
descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
118
|
+
try:
|
|
119
|
+
info = os.fstat(descriptor)
|
|
120
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
121
|
+
or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600):
|
|
122
|
+
raise PermissionError("uninstall ownership lease is unsafe")
|
|
123
|
+
try:
|
|
124
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
125
|
+
except BlockingIOError:
|
|
126
|
+
raise RuntimeError("another process still owns execution or candidate preparation") from None
|
|
127
|
+
if _identity(path) != (info.st_dev, info.st_ino):
|
|
128
|
+
raise RuntimeError("uninstall ownership lease changed")
|
|
129
|
+
yield
|
|
130
|
+
finally:
|
|
131
|
+
os.close(descriptor)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _hold_preparation_leases(root: Path, stack: ExitStack) -> None:
|
|
135
|
+
parent = root / ".update-preparations"
|
|
136
|
+
if not parent.exists() and not parent.is_symlink():
|
|
137
|
+
return
|
|
138
|
+
files._owned_directory(parent, private=True)
|
|
139
|
+
for directory in sorted(parent.iterdir()):
|
|
140
|
+
if re.fullmatch(r"[0-9a-f]{32}", directory.name) is None:
|
|
141
|
+
raise RuntimeError("unknown candidate preparation directory; no files were removed")
|
|
142
|
+
files._owned_directory(directory, private=True)
|
|
143
|
+
lock = directory / "runner.lock"
|
|
144
|
+
if lock.exists() or lock.is_symlink():
|
|
145
|
+
stack.enter_context(_owned_lease(lock))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _service_files(layout: files.ExecutionLayout) -> dict[str, tuple[bytes, int]]:
|
|
149
|
+
result = {}
|
|
150
|
+
for role in ("worker", "gateway"):
|
|
151
|
+
path = files._path(layout.service_path(role))
|
|
152
|
+
files._owned_directory(path.parent)
|
|
153
|
+
data, mode = files._read_file(path)
|
|
154
|
+
if data != files.render_service(layout, role) or mode not in {0o600, 0o644}:
|
|
155
|
+
raise RuntimeError("native service configuration is not this installed execution layout")
|
|
156
|
+
result[role] = data, mode
|
|
157
|
+
if not layout.current.is_symlink() or layout.current.resolve(strict=True) != layout.gateway_release:
|
|
158
|
+
raise RuntimeError("gateway current link no longer matches its installed generation")
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _removable_tree(path: Path, *, device: int | None = None) -> None:
|
|
163
|
+
"""Inspect owned descendants behind a private root without following links.
|
|
164
|
+
|
|
165
|
+
uv and Python can inherit umask 0002, producing 0775 directories inside
|
|
166
|
+
this 0700 boundary. Those bits grant no traversal through the private root.
|
|
167
|
+
Requiring every descendant to be 0700/0755 would strand legitimate installs.
|
|
168
|
+
"""
|
|
169
|
+
info = path.lstat()
|
|
170
|
+
if device is None:
|
|
171
|
+
files._owned_directory(path, private=True)
|
|
172
|
+
device = info.st_dev
|
|
173
|
+
else:
|
|
174
|
+
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
|
|
175
|
+
or stat.S_IMODE(info.st_mode) & 0o700 != 0o700):
|
|
176
|
+
raise PermissionError("managed removal tree contains an inaccessible or foreign directory")
|
|
177
|
+
if info.st_dev != device:
|
|
178
|
+
raise RuntimeError("managed removal tree contains another mounted filesystem")
|
|
179
|
+
for child in path.iterdir():
|
|
180
|
+
child_info = child.lstat()
|
|
181
|
+
if stat.S_ISDIR(child_info.st_mode):
|
|
182
|
+
_removable_tree(child, device=device)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _remove_child(path: Path) -> None:
|
|
186
|
+
if stat.S_ISDIR(path.lstat().st_mode):
|
|
187
|
+
if not shutil.rmtree.avoids_symlink_attacks:
|
|
188
|
+
raise RuntimeError("this Python cannot safely remove a managed directory")
|
|
189
|
+
shutil.rmtree(path)
|
|
190
|
+
else:
|
|
191
|
+
path.unlink()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
|
|
195
|
+
purge_state: bool = False, services: NativeServices | None = None,
|
|
196
|
+
control: WorkerControl | None = None) -> dict:
|
|
197
|
+
root, config_root, state_root, home = map(files._path, (root, config_root, state_root, home))
|
|
198
|
+
with InstallationLock(root), ExitStack() as leases:
|
|
199
|
+
_no_activation(root)
|
|
200
|
+
from execution_recovery import require_retired_owners
|
|
201
|
+
require_retired_owners(root)
|
|
202
|
+
layout = _installed_layout(root)
|
|
203
|
+
if (layout.config_root, layout.state_root, layout.home) != (config_root, state_root, home):
|
|
204
|
+
raise RuntimeError("uninstall roots do not match the installed execution layout")
|
|
205
|
+
native = services or NativeServices(layout)
|
|
206
|
+
control = control or WorkerControl()
|
|
207
|
+
originals = _service_files(layout)
|
|
208
|
+
_no_pending_update(layout)
|
|
209
|
+
_hold_preparation_leases(root, leases)
|
|
210
|
+
_removable_tree(root)
|
|
211
|
+
_removable_tree(config_root)
|
|
212
|
+
if purge_state:
|
|
213
|
+
_removable_tree(state_root)
|
|
214
|
+
roots = {path: _identity(path) for path in (root, config_root, state_root)}
|
|
215
|
+
intent = _read_intent(root, layout)
|
|
216
|
+
if intent is not None and intent["purge_state"] != purge_state:
|
|
217
|
+
raise RuntimeError("retry must preserve the existing uninstall data-removal choice")
|
|
218
|
+
prior = native.snapshot()
|
|
219
|
+
# An interrupted graceful stop can precede the stopped-phase write.
|
|
220
|
+
# The exact intent, empty child registry and exclusive state lease
|
|
221
|
+
# below supply the missing proof; an unjournaled stopped install fails.
|
|
222
|
+
stopped_resume = (intent is not None
|
|
223
|
+
and all(prior[role].get("state") in {"stopped", "absent"} for role in ("worker", "gateway")))
|
|
224
|
+
partial_resume = (intent is not None and prior["worker"].get("state") == "running"
|
|
225
|
+
and prior["gateway"].get("state") in {"stopped", "absent"})
|
|
226
|
+
if not stopped_resume and not partial_resume and any(prior[role].get("state") != "running" for role in ("worker", "gateway")):
|
|
227
|
+
raise RuntimeError("safe uninstall requires a healthy running worker and gateway; stopped or broken installations require ownership recovery")
|
|
228
|
+
if partial_resume:
|
|
229
|
+
record, status = control.status(layout)
|
|
230
|
+
lease = status.get("lease")
|
|
231
|
+
if (record.get("pid") != prior["worker"]["pid"] or record.get("release_root") != str(layout.worker_release)
|
|
232
|
+
or status.get("idle") is not True or not isinstance(lease, dict)
|
|
233
|
+
or lease.get("operation_id") != intent["operation_id"] or lease.get("sealed") is not True):
|
|
234
|
+
raise RuntimeError("interrupted uninstall no longer owns the running worker")
|
|
235
|
+
health = control.callback_health(layout, record)
|
|
236
|
+
worker = health.get("execution_service")
|
|
237
|
+
if (health.get("ok") is not True or health.get("server_identity") != intent["server_identity"]
|
|
238
|
+
or not isinstance(worker, dict) or worker.get("pid") != record["pid"]
|
|
239
|
+
or worker.get("instance_id") != record.get("instance_id")):
|
|
240
|
+
raise RuntimeError("interrupted uninstall worker health identity changed")
|
|
241
|
+
receipt = {"worker_pid": record["pid"], "gateway_pid": prior["gateway"].get("pid"),
|
|
242
|
+
"worker_release": str(layout.worker_release), "worker_instance_id": record["instance_id"],
|
|
243
|
+
"server_identity": health["server_identity"]}
|
|
244
|
+
else:
|
|
245
|
+
receipt = {} if stopped_resume else control.receipt(layout, native)
|
|
246
|
+
if not stopped_resume and (receipt.get("worker_pid") != prior["worker"].get("pid")
|
|
247
|
+
or receipt.get("gateway_pid") != prior["gateway"].get("pid")
|
|
248
|
+
or receipt.get("worker_release") != str(layout.worker_release)
|
|
249
|
+
or not receipt.get("server_identity")):
|
|
250
|
+
raise RuntimeError("authenticated runtime is not owned by both installed native jobs")
|
|
251
|
+
operation = intent["operation_id"] if intent else str(uuid.uuid4())
|
|
252
|
+
sealed = None
|
|
253
|
+
native_changed = False
|
|
254
|
+
deleting = False
|
|
255
|
+
try:
|
|
256
|
+
if not stopped_resume:
|
|
257
|
+
if intent is None:
|
|
258
|
+
record, status = control.status(layout)
|
|
259
|
+
if (record.get("instance_id") != receipt["worker_instance_id"]
|
|
260
|
+
or record.get("pid") != prior["worker"]["pid"]
|
|
261
|
+
or status.get("idle") is not True or status.get("lease") is not None):
|
|
262
|
+
raise RuntimeError("execution is busy or another operation owns admission")
|
|
263
|
+
# Persist before acquiring the lease: a lost seal response
|
|
264
|
+
# must not strand an otherwise unidentifiable hold.
|
|
265
|
+
intent = _write_intent(root, layout, operation, purge_state, receipt["server_identity"])
|
|
266
|
+
sealed = control.seal_for_stop(layout, operation, prior["worker"]["pid"])
|
|
267
|
+
if sealed["record"].get("instance_id") != receipt.get("worker_instance_id"):
|
|
268
|
+
raise RuntimeError("worker epoch changed before uninstall admission")
|
|
269
|
+
intent = {**intent, "phase": "sealed"}
|
|
270
|
+
files._atomic_write(root / INTENT_NAME, files._json_bytes(intent))
|
|
271
|
+
_no_pending_update(layout)
|
|
272
|
+
_no_activation(root)
|
|
273
|
+
if _service_files(layout) != originals or native.snapshot() != prior:
|
|
274
|
+
raise RuntimeError("native service ownership changed before uninstall")
|
|
275
|
+
# Disabling boot activation precedes the graceful stops; no forced
|
|
276
|
+
# signal or broad process-group/provider cleanup is ever used.
|
|
277
|
+
native_changed = True
|
|
278
|
+
for role in (() if stopped_resume else ("gateway", "worker")):
|
|
279
|
+
native.set_enabled(role, False)
|
|
280
|
+
for role in (() if stopped_resume else ("gateway", "worker")):
|
|
281
|
+
if prior[role].get("state") in {"stopped", "absent"}:
|
|
282
|
+
continue
|
|
283
|
+
observed = native.snapshot()[role]
|
|
284
|
+
if observed.get("state") != "running" or observed.get("pid") != prior[role]["pid"]:
|
|
285
|
+
raise RuntimeError("native process changed before its authorized stop")
|
|
286
|
+
if role == "worker":
|
|
287
|
+
record, status = control.status(layout)
|
|
288
|
+
if (record != sealed["record"] or status.get("lease") != sealed["lease"]
|
|
289
|
+
or status.get("idle") is not True):
|
|
290
|
+
raise RuntimeError("worker admission changed before its authorized stop")
|
|
291
|
+
native.stop(role)
|
|
292
|
+
stopped = native.snapshot()
|
|
293
|
+
if any(stopped[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
|
|
294
|
+
raise RuntimeError("native services did not both stop; all files were retained")
|
|
295
|
+
# Native PID disappearance is insufficient if a separate process
|
|
296
|
+
# still owns the state store. Retain this lease through deletion.
|
|
297
|
+
leases.enter_context(_owned_lease(state_root / "admin/state-owner.lock"))
|
|
298
|
+
_no_provider_children(state_root)
|
|
299
|
+
_no_pending_update(layout)
|
|
300
|
+
_no_activation(root)
|
|
301
|
+
if _service_files(layout) != originals or any(_identity(path) != identity for path, identity in roots.items()):
|
|
302
|
+
raise RuntimeError("managed files changed during service shutdown")
|
|
303
|
+
if _read_intent(root, layout) != intent:
|
|
304
|
+
raise RuntimeError("uninstall intent changed before removal")
|
|
305
|
+
intent = {**intent, "phase": "stopped"}
|
|
306
|
+
files._atomic_write(root / INTENT_NAME, files._json_bytes(intent))
|
|
307
|
+
deleting = True
|
|
308
|
+
for role in ("gateway", "worker"):
|
|
309
|
+
layout.service_path(role).unlink()
|
|
310
|
+
native.reload()
|
|
311
|
+
for child in root.iterdir():
|
|
312
|
+
if child.name != ".install-lock":
|
|
313
|
+
_remove_child(child)
|
|
314
|
+
_remove_child(config_root)
|
|
315
|
+
if purge_state:
|
|
316
|
+
_remove_child(state_root)
|
|
317
|
+
legacy_alias = home / ".zenithbot-agent"
|
|
318
|
+
if (legacy_alias.is_symlink() and legacy_alias.lstat().st_uid == os.getuid()
|
|
319
|
+
and legacy_alias.resolve(strict=False) == state_root):
|
|
320
|
+
legacy_alias.unlink()
|
|
321
|
+
files._fsync_directory(root)
|
|
322
|
+
except BaseException as original_error:
|
|
323
|
+
if sealed is None and intent is not None and not stopped_resume:
|
|
324
|
+
# A transport failure can occur after the worker accepted the
|
|
325
|
+
# lease. Inspect that exact operation once; never acquire a
|
|
326
|
+
# replacement lease or discard another epoch's evidence.
|
|
327
|
+
record, status = control.status(layout)
|
|
328
|
+
if (record.get("instance_id") == receipt.get("worker_instance_id")
|
|
329
|
+
and record.get("pid") == prior["worker"].get("pid")):
|
|
330
|
+
lease = status.get("lease")
|
|
331
|
+
if lease is None and _read_intent(root, layout) == intent:
|
|
332
|
+
(root / INTENT_NAME).unlink()
|
|
333
|
+
files._fsync_directory(root)
|
|
334
|
+
intent = None
|
|
335
|
+
elif isinstance(lease, dict) and lease.get("operation_id") == operation:
|
|
336
|
+
sealed = {"record": record, "lease": lease}
|
|
337
|
+
# Only the same still-running worker can have its exact lease
|
|
338
|
+
# released. A stopped or uncertain epoch is never restarted here.
|
|
339
|
+
if sealed is not None and not deleting:
|
|
340
|
+
try:
|
|
341
|
+
current = native.snapshot()
|
|
342
|
+
if current["worker"].get("pid") != prior["worker"].get("pid") or current["worker"].get("state") != "running":
|
|
343
|
+
raise RuntimeError("worker no longer has its original running identity")
|
|
344
|
+
if _service_files(layout) != originals:
|
|
345
|
+
raise RuntimeError("service configuration changed")
|
|
346
|
+
if native_changed:
|
|
347
|
+
if prior["gateway"]["state"] == "running" and current["gateway"].get("state") != "running":
|
|
348
|
+
native.start("gateway")
|
|
349
|
+
for role in ("worker", "gateway"):
|
|
350
|
+
native.set_enabled(role, prior[role]["enabled"])
|
|
351
|
+
record, status = control.status(layout)
|
|
352
|
+
lease = status.get("lease")
|
|
353
|
+
if (record != sealed["record"] or not isinstance(lease, dict)
|
|
354
|
+
or lease.get("operation_id") != operation or lease.get("lease_id") != sealed["lease"].get("lease_id")):
|
|
355
|
+
raise RuntimeError("worker lease changed")
|
|
356
|
+
result = control._maintenance(layout, record, action="release", operation=operation,
|
|
357
|
+
lease_id=lease["lease_id"])
|
|
358
|
+
if result.get("lease", "missing") is not None:
|
|
359
|
+
raise RuntimeError("worker did not release uninstall admission")
|
|
360
|
+
if intent is not None:
|
|
361
|
+
if _read_intent(root, layout) != intent:
|
|
362
|
+
raise RuntimeError("uninstall intent changed during recovery")
|
|
363
|
+
(root / INTENT_NAME).unlink()
|
|
364
|
+
files._fsync_directory(root)
|
|
365
|
+
except Exception as recovery_error:
|
|
366
|
+
raise RuntimeError("uninstall stopped before file removal; service/admission recovery requires ownership verification") from recovery_error
|
|
367
|
+
raise original_error
|
|
368
|
+
# The shared lock remains present until every requested removal completes.
|
|
369
|
+
root.rmdir()
|
|
370
|
+
return {"removed": True, "state_preserved": not purge_state, "state_root": str(state_root)}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main(argv: list[str] | None = None) -> int:
|
|
374
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
375
|
+
for name in ("root", "config-root", "state-root", "home"):
|
|
376
|
+
parser.add_argument("--" + name, type=Path, required=True)
|
|
377
|
+
parser.add_argument("--yes", action="store_true")
|
|
378
|
+
parser.add_argument("--purge-state", action="store_true")
|
|
379
|
+
arguments = parser.parse_args(argv)
|
|
380
|
+
try:
|
|
381
|
+
# Validate provenance before prompting or executing native operations.
|
|
382
|
+
_no_activation(arguments.root)
|
|
383
|
+
_installed_layout(files._path(arguments.root))
|
|
384
|
+
if arguments.purge_state:
|
|
385
|
+
if not sys.stdin.isatty():
|
|
386
|
+
raise RuntimeError("Refusing to purge state without an interactive terminal; --yes never bypasses the state guard")
|
|
387
|
+
if input(f"Type the exact state path to confirm ({arguments.state_root}): ") != str(arguments.state_root):
|
|
388
|
+
raise RuntimeError("State path did not match; nothing was changed")
|
|
389
|
+
if not arguments.yes:
|
|
390
|
+
if not sys.stdin.isatty() or input("Remove both idle services, runtime and configuration? [y/N] ").lower() not in {"y", "yes"}:
|
|
391
|
+
raise RuntimeError("Uninstall was not confirmed; nothing was changed")
|
|
392
|
+
result = uninstall(arguments.root, config_root=arguments.config_root, state_root=arguments.state_root,
|
|
393
|
+
home=arguments.home, purge_state=arguments.purge_state)
|
|
394
|
+
print("AgentsServer gateway, execution service, release runtime and configuration removed.")
|
|
395
|
+
if result["state_preserved"]:
|
|
396
|
+
print("Preserved chat history, jobs, files, Team Hub state and secure-peer credentials at " + result["state_root"])
|
|
397
|
+
print("Persistent chat terminals remain independent and were not stopped.")
|
|
398
|
+
return 0
|
|
399
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
400
|
+
# NativeServices/WorkerControl never echo service environments/tokens.
|
|
401
|
+
print("Cannot uninstall the separate gateway/execution layout: " + str(error), file=sys.stderr)
|
|
402
|
+
return 1
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Prove a bundled release is running in both independently managed services.
|
|
2
|
+
|
|
3
|
+
Release files and a successful worker boot are not completion evidence. The
|
|
4
|
+
native service owners must match authenticated public health, and an unfinished
|
|
5
|
+
activation remains incomplete even after it has published the new files.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import execution_install as files
|
|
14
|
+
from execution_manage import NativeServices, WorkerControl
|
|
15
|
+
from update_runner import version_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def current_components(
|
|
19
|
+
root: Path, *, target_version: str, expected_server_identity: str,
|
|
20
|
+
expected_worker_instance: str,
|
|
21
|
+
) -> bool:
|
|
22
|
+
"""Read-only proof, invoked off the server event loop for an equal-version ensure."""
|
|
23
|
+
root = files._path(root)
|
|
24
|
+
files._owned_directory(root)
|
|
25
|
+
for name in (".activation-transaction", ".execution-transaction"):
|
|
26
|
+
journal = root / name
|
|
27
|
+
if journal.exists() or journal.is_symlink():
|
|
28
|
+
return False
|
|
29
|
+
data, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
|
|
30
|
+
document = json.loads(data)
|
|
31
|
+
if not isinstance(document, dict) or document.get("format") != files.FORMAT:
|
|
32
|
+
raise ValueError("installed execution layout is invalid")
|
|
33
|
+
layout = files.ExecutionLayout.from_dict(document.get("layout"))
|
|
34
|
+
if layout.install_root != root or document != files.layout_manifest(layout):
|
|
35
|
+
raise ValueError("installed execution layout does not match its runtime")
|
|
36
|
+
receipt: dict[str, Any] = WorkerControl().receipt(layout, NativeServices(layout))
|
|
37
|
+
if (receipt.get("server_identity") != expected_server_identity
|
|
38
|
+
or receipt.get("worker_instance_id") != expected_worker_instance):
|
|
39
|
+
raise RuntimeError("running execution ownership changed during update reconciliation")
|
|
40
|
+
target = version_key(target_version)
|
|
41
|
+
complete = (receipt.get("maintenance_held") is False
|
|
42
|
+
and version_key(receipt.get("worker_version", "")) >= target
|
|
43
|
+
and version_key(receipt.get("gateway_version", "")) >= target)
|
|
44
|
+
# A new activation can begin while the read-only native probes are in flight.
|
|
45
|
+
# Do not claim completion over the operation that just acquired ownership.
|
|
46
|
+
return complete and not any(
|
|
47
|
+
(root / name).exists() or (root / name).is_symlink()
|
|
48
|
+
for name in (".activation-transaction", ".execution-transaction")
|
|
49
|
+
)
|