netizen-cli 0.10.0__py3-none-any.whl
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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Defaults metadata on the existing Channel database and transaction boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from ..session_settings import SessionSettings
|
|
11
|
+
from .models import DefaultConfigurationError, DefaultRule
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..bindings import BindingStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_GROUP_RULES = 200
|
|
18
|
+
SCHEMA = (
|
|
19
|
+
"""CREATE TABLE session_defaults (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
app_id TEXT NOT NULL CHECK(length(app_id) > 0),
|
|
22
|
+
kind TEXT NOT NULL CHECK(kind IN ('chat', 'group_name')),
|
|
23
|
+
chat_id TEXT, keyword TEXT,
|
|
24
|
+
project TEXT NOT NULL CHECK(length(project) > 0),
|
|
25
|
+
session_settings_json TEXT NOT NULL,
|
|
26
|
+
revision INTEGER NOT NULL CHECK(typeof(revision) = 'integer' AND revision >= 1),
|
|
27
|
+
position INTEGER,
|
|
28
|
+
CHECK((kind = 'chat' AND chat_id IS NOT NULL AND length(chat_id) > 0
|
|
29
|
+
AND keyword IS NULL AND position IS NULL) OR
|
|
30
|
+
(kind = 'group_name' AND chat_id IS NULL AND keyword IS NOT NULL
|
|
31
|
+
AND length(trim(keyword)) > 0 AND typeof(position) = 'integer' AND position >= 0))
|
|
32
|
+
)""",
|
|
33
|
+
"CREATE UNIQUE INDEX session_defaults_chat ON session_defaults(app_id, chat_id) WHERE kind = 'chat'",
|
|
34
|
+
"CREATE UNIQUE INDEX session_defaults_position ON session_defaults(app_id, position) WHERE kind = 'group_name'",
|
|
35
|
+
"""CREATE TABLE session_defaults_order (
|
|
36
|
+
app_id TEXT PRIMARY KEY CHECK(length(app_id) > 0),
|
|
37
|
+
revision INTEGER NOT NULL CHECK(typeof(revision) = 'integer' AND revision >= 1)
|
|
38
|
+
)""",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_schema(connection: sqlite3.Connection) -> None:
|
|
43
|
+
for statement in SCHEMA:
|
|
44
|
+
connection.execute(statement)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_schema(connection: sqlite3.Connection) -> None:
|
|
48
|
+
expected = {
|
|
49
|
+
"session_defaults": {
|
|
50
|
+
"id": ("TEXT", 0, 1), "app_id": ("TEXT", 1, 0),
|
|
51
|
+
"kind": ("TEXT", 1, 0), "chat_id": ("TEXT", 0, 0),
|
|
52
|
+
"keyword": ("TEXT", 0, 0), "project": ("TEXT", 1, 0),
|
|
53
|
+
"session_settings_json": ("TEXT", 1, 0),
|
|
54
|
+
"revision": ("INTEGER", 1, 0), "position": ("INTEGER", 0, 0),
|
|
55
|
+
},
|
|
56
|
+
"session_defaults_order": {"app_id": ("TEXT", 0, 1), "revision": ("INTEGER", 1, 0)},
|
|
57
|
+
}
|
|
58
|
+
for table, columns in expected.items():
|
|
59
|
+
actual = {row["name"]: (row["type"].upper(), row["notnull"], row["pk"])
|
|
60
|
+
for row in connection.execute(f"PRAGMA table_info({table})")}
|
|
61
|
+
if any(actual.get(name) != shape for name, shape in columns.items()):
|
|
62
|
+
raise RuntimeError(f"current Channel database has invalid {table} columns")
|
|
63
|
+
indexes = {row["name"]: row for row in connection.execute("PRAGMA index_list(session_defaults)")}
|
|
64
|
+
for name, columns, predicate in (
|
|
65
|
+
("session_defaults_chat", ("app_id", "chat_id"), "kind = 'chat'"),
|
|
66
|
+
("session_defaults_position", ("app_id", "position"), "kind = 'group_name'"),
|
|
67
|
+
):
|
|
68
|
+
index = indexes.get(name)
|
|
69
|
+
if index is None or not index["unique"] or not index["partial"]:
|
|
70
|
+
raise RuntimeError(f"current defaults identity index is missing or invalid: {name}")
|
|
71
|
+
actual_columns = tuple(row["name"] for row in connection.execute(f"PRAGMA index_info({name})"))
|
|
72
|
+
sql = connection.execute("SELECT sql FROM sqlite_master WHERE name = ?", (name,)).fetchone()[0]
|
|
73
|
+
if actual_columns != columns or " ".join(sql.lower().split()).partition(" where ")[2] != predicate:
|
|
74
|
+
raise RuntimeError(f"current defaults identity index has invalid shape: {name}")
|
|
75
|
+
for table in expected:
|
|
76
|
+
if connection.execute(f"PRAGMA foreign_key_list({table})").fetchone():
|
|
77
|
+
raise RuntimeError("default configuration must not link Project lifecycle")
|
|
78
|
+
if connection.execute(
|
|
79
|
+
"SELECT 1 FROM session_defaults_order WHERE app_id IS NULL OR app_id = '' "
|
|
80
|
+
"OR typeof(revision) != 'integer' OR revision < 1 LIMIT 1"
|
|
81
|
+
).fetchone():
|
|
82
|
+
raise RuntimeError("current defaults order revision is invalid")
|
|
83
|
+
positions: dict[str, list[int]] = {}
|
|
84
|
+
for row in connection.execute("SELECT * FROM session_defaults ORDER BY app_id, position"):
|
|
85
|
+
try:
|
|
86
|
+
rule = _rule(row)
|
|
87
|
+
if not all(isinstance(value, str) and value for value in (rule.id, rule.app_id, rule.project)):
|
|
88
|
+
raise ValueError("missing identity")
|
|
89
|
+
if type(rule.revision) is not int or rule.revision < 1:
|
|
90
|
+
raise ValueError("invalid revision")
|
|
91
|
+
if rule.kind == "chat":
|
|
92
|
+
if not rule.chat_id or rule.keyword is not None or rule.position is not None:
|
|
93
|
+
raise ValueError("invalid chat rule")
|
|
94
|
+
elif rule.kind == "group_name":
|
|
95
|
+
if (rule.chat_id is not None or not isinstance(rule.keyword, str)
|
|
96
|
+
or not rule.keyword.strip() or rule.keyword != rule.keyword.strip()
|
|
97
|
+
or type(rule.position) is not int or rule.position < 0):
|
|
98
|
+
raise ValueError("invalid group rule")
|
|
99
|
+
positions.setdefault(rule.app_id, []).append(rule.position)
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError("invalid rule kind")
|
|
102
|
+
except (TypeError, ValueError) as error:
|
|
103
|
+
raise RuntimeError("current default configuration is invalid") from error
|
|
104
|
+
for app_id, values in positions.items():
|
|
105
|
+
if len(values) > MAX_GROUP_RULES or values != list(range(len(values))):
|
|
106
|
+
raise RuntimeError("current defaults group order is invalid")
|
|
107
|
+
if connection.execute("SELECT 1 FROM session_defaults_order WHERE app_id = ?", (app_id,)).fetchone() is None:
|
|
108
|
+
raise RuntimeError("current defaults group order revision is missing")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _rule(row: sqlite3.Row) -> DefaultRule:
|
|
112
|
+
return DefaultRule(
|
|
113
|
+
id=row["id"], app_id=row["app_id"], kind=row["kind"],
|
|
114
|
+
chat_id=row["chat_id"], keyword=row["keyword"], project=row["project"],
|
|
115
|
+
session_settings=SessionSettings.from_dict(json.loads(row["session_settings_json"])),
|
|
116
|
+
revision=row["revision"], position=row["position"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DefaultsStore:
|
|
121
|
+
def __init__(self, owner: BindingStore) -> None:
|
|
122
|
+
self.owner = owner
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def _db(self) -> sqlite3.Connection:
|
|
126
|
+
return self.owner._connection
|
|
127
|
+
|
|
128
|
+
def get(self, app_id: str, rule_id: str) -> DefaultRule:
|
|
129
|
+
with self.owner._lock:
|
|
130
|
+
row = self._db.execute("SELECT * FROM session_defaults WHERE app_id = ? AND id = ?", (app_id, rule_id)).fetchone()
|
|
131
|
+
if row is None:
|
|
132
|
+
raise DefaultConfigurationError("默认会话配置不存在,请刷新后重试。", code="not_found")
|
|
133
|
+
return _rule(row)
|
|
134
|
+
|
|
135
|
+
def exact(self, app_id: str, chat_id: str) -> DefaultRule | None:
|
|
136
|
+
with self.owner._lock:
|
|
137
|
+
row = self._db.execute(
|
|
138
|
+
"SELECT * FROM session_defaults WHERE app_id = ? AND kind = 'chat' AND chat_id = ?", (app_id, chat_id),
|
|
139
|
+
).fetchone()
|
|
140
|
+
return _rule(row) if row else None
|
|
141
|
+
|
|
142
|
+
def group_rules(self, app_id: str) -> tuple[DefaultRule, ...]:
|
|
143
|
+
with self.owner._lock:
|
|
144
|
+
return tuple(_rule(row) for row in self._db.execute(
|
|
145
|
+
"SELECT * FROM session_defaults WHERE app_id = ? AND kind = 'group_name' ORDER BY position LIMIT ?",
|
|
146
|
+
(app_id, MAX_GROUP_RULES),
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
async def list(self, app_id: str, kind: str, *, offset: int, limit: int) -> dict[str, Any]:
|
|
150
|
+
def query(connection: sqlite3.Connection) -> dict[str, Any]:
|
|
151
|
+
connection.execute("BEGIN")
|
|
152
|
+
try:
|
|
153
|
+
rows = connection.execute(
|
|
154
|
+
"SELECT * FROM session_defaults WHERE app_id = ? AND kind = ? "
|
|
155
|
+
"ORDER BY position, id LIMIT ? OFFSET ?", (app_id, kind, limit + 1, offset),
|
|
156
|
+
).fetchall()
|
|
157
|
+
return {"items": [_rule(row).to_dict() for row in rows[:limit]],
|
|
158
|
+
"has_more": len(rows) > limit, "offset": offset,
|
|
159
|
+
"order_revision": self._order_revision(connection, app_id)}
|
|
160
|
+
finally:
|
|
161
|
+
connection.rollback()
|
|
162
|
+
return await self.owner._submit_query(query, deadline_seconds=2)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _order_revision(connection: sqlite3.Connection, app_id: str) -> int:
|
|
166
|
+
row = connection.execute("SELECT revision FROM session_defaults_order WHERE app_id = ?", (app_id,)).fetchone()
|
|
167
|
+
return row[0] if row else 1
|
|
168
|
+
|
|
169
|
+
def _bump_order(self, app_id: str) -> int:
|
|
170
|
+
self._db.execute(
|
|
171
|
+
"INSERT INTO session_defaults_order(app_id, revision) VALUES (?, 2) "
|
|
172
|
+
"ON CONFLICT(app_id) DO UPDATE SET revision = revision + 1", (app_id,),
|
|
173
|
+
)
|
|
174
|
+
return self._order_revision(self._db, app_id)
|
|
175
|
+
|
|
176
|
+
def save(
|
|
177
|
+
self, *, app_id: str, kind: str, chat_id: str | None, keyword: str | None,
|
|
178
|
+
project: str, session_settings: SessionSettings, rule_id: str | None,
|
|
179
|
+
expected_revision: int | None, checked_project_revision: int | None = None,
|
|
180
|
+
) -> DefaultRule:
|
|
181
|
+
with self.owner._transaction():
|
|
182
|
+
if checked_project_revision is not None:
|
|
183
|
+
self._require_checked_project(project, checked_project_revision)
|
|
184
|
+
if expected_revision is None:
|
|
185
|
+
if rule_id is not None:
|
|
186
|
+
raise DefaultConfigurationError("新增默认配置不能指定已有 ID。")
|
|
187
|
+
if kind == "chat" and self.exact(app_id, chat_id or "") is not None:
|
|
188
|
+
raise DefaultConfigurationError("该聊天已有默认配置,请刷新后修改。", code="revision_conflict")
|
|
189
|
+
position = None
|
|
190
|
+
if kind == "group_name":
|
|
191
|
+
position = self._db.execute(
|
|
192
|
+
"SELECT count(*) FROM session_defaults WHERE app_id = ? AND kind = 'group_name'", (app_id,),
|
|
193
|
+
).fetchone()[0]
|
|
194
|
+
if position >= MAX_GROUP_RULES:
|
|
195
|
+
raise DefaultConfigurationError(f"群名规则最多支持 {MAX_GROUP_RULES} 条。", code="capacity")
|
|
196
|
+
self._bump_order(app_id)
|
|
197
|
+
rule_id = str(uuid.uuid4())
|
|
198
|
+
self._db.execute(
|
|
199
|
+
"INSERT INTO session_defaults VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
|
200
|
+
(rule_id, app_id, kind, chat_id, keyword, project,
|
|
201
|
+
json.dumps(session_settings.to_dict(), ensure_ascii=False), position),
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
if rule_id is None:
|
|
205
|
+
raise DefaultConfigurationError("修改默认配置需要 ID。")
|
|
206
|
+
previous = self.get(app_id, rule_id)
|
|
207
|
+
self._check_revision(previous, expected_revision)
|
|
208
|
+
if kind != previous.kind or chat_id != previous.chat_id:
|
|
209
|
+
raise DefaultConfigurationError("默认配置的类型和聊天不可修改;请新建配置。")
|
|
210
|
+
self._db.execute(
|
|
211
|
+
"UPDATE session_defaults SET keyword = ?, project = ?, session_settings_json = ?, "
|
|
212
|
+
"revision = revision + 1 WHERE id = ? AND app_id = ?",
|
|
213
|
+
(keyword, project, json.dumps(session_settings.to_dict(), ensure_ascii=False), rule_id, app_id),
|
|
214
|
+
)
|
|
215
|
+
return self.get(app_id, rule_id)
|
|
216
|
+
|
|
217
|
+
def _require_checked_project(self, alias: str, checked_revision: int) -> None:
|
|
218
|
+
from ..bindings import ProjectConflict, ProjectNotFound
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
self.owner.require_project_not_deleting(alias)
|
|
222
|
+
project = self.owner.get_project(alias)
|
|
223
|
+
except (ProjectConflict, ProjectNotFound) as error:
|
|
224
|
+
raise DefaultConfigurationError("Project 已不可用于新建会话,请刷新后重试。", code="project_unavailable") from error
|
|
225
|
+
if project.revision != checked_revision or not project.enabled:
|
|
226
|
+
raise DefaultConfigurationError("Project 已被其他操作修改,请刷新后重试。", code="project_unavailable")
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _check_revision(rule: DefaultRule, revision: int) -> None:
|
|
230
|
+
if rule.revision != revision:
|
|
231
|
+
raise DefaultConfigurationError("默认配置已被其他操作修改,请刷新后重试。", code="revision_conflict")
|
|
232
|
+
|
|
233
|
+
def delete(self, app_id: str, rule_id: str, expected_revision: int) -> None:
|
|
234
|
+
with self.owner._transaction():
|
|
235
|
+
rule = self.get(app_id, rule_id)
|
|
236
|
+
self._check_revision(rule, expected_revision)
|
|
237
|
+
self._db.execute("DELETE FROM session_defaults WHERE app_id = ? AND id = ?", (app_id, rule_id))
|
|
238
|
+
if rule.kind == "group_name":
|
|
239
|
+
self._write_order(app_id, [item.id for item in self.group_rules(app_id)])
|
|
240
|
+
self._bump_order(app_id)
|
|
241
|
+
|
|
242
|
+
def reorder(self, app_id: str, rule_ids: list[str], order_revision: int) -> int:
|
|
243
|
+
with self.owner._transaction():
|
|
244
|
+
if self._order_revision(self._db, app_id) != order_revision:
|
|
245
|
+
raise DefaultConfigurationError("群名规则顺序已变化,请刷新后重试。", code="revision_conflict")
|
|
246
|
+
current = [rule.id for rule in self.group_rules(app_id)]
|
|
247
|
+
if len(rule_ids) != len(set(rule_ids)) or set(rule_ids) != set(current):
|
|
248
|
+
raise DefaultConfigurationError("排序必须包含当前应用下全部群名规则,且不能重复。")
|
|
249
|
+
self._write_order(app_id, rule_ids)
|
|
250
|
+
return self._bump_order(app_id)
|
|
251
|
+
|
|
252
|
+
def _write_order(self, app_id: str, rule_ids: list[str]) -> None:
|
|
253
|
+
# Move out of the dense final range before assigning the new positions;
|
|
254
|
+
# the unique index remains valid throughout the transaction.
|
|
255
|
+
self._db.execute(
|
|
256
|
+
"UPDATE session_defaults SET position = position + ? WHERE app_id = ? AND kind = 'group_name'",
|
|
257
|
+
(MAX_GROUP_RULES + 1, app_id),
|
|
258
|
+
)
|
|
259
|
+
for position, rule_id in enumerate(rule_ids):
|
|
260
|
+
self._db.execute("UPDATE session_defaults SET position = ? WHERE app_id = ? AND id = ?", (position, app_id, rule_id))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared deployment support; safe to import before candidate setup."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""One-shot, instance-only restart dispatched by the service manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .update_protocol import (
|
|
12
|
+
UpdateProtocolError, acquire_install_lock, advance_operation, read_operation,
|
|
13
|
+
terminal_phase, validate_inherited_lock,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
START_HANDOFF_SECONDS = 60
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assert_no_pending_restart(root: Path, *, lock_descriptor: int,
|
|
20
|
+
now: float | None = None) -> dict[str, Any] | None:
|
|
21
|
+
"""Fence delayed Admin work before a CLI mutation, under the exact lock.
|
|
22
|
+
|
|
23
|
+
A fresh accepted dispatch keeps its bounded handoff window. Acquiring this
|
|
24
|
+
lock proves no worker is currently in its stop/start critical section. A
|
|
25
|
+
stale record is therefore fenced before returning, and a delayed worker
|
|
26
|
+
will reject it without recreating a service or maintenance record.
|
|
27
|
+
"""
|
|
28
|
+
validate_inherited_lock(root, lock_descriptor)
|
|
29
|
+
operation = read_operation(root)
|
|
30
|
+
if operation is None or terminal_phase(operation["phase"]):
|
|
31
|
+
return operation
|
|
32
|
+
if operation.get("schema") != 3:
|
|
33
|
+
raise UpdateProtocolError(
|
|
34
|
+
"Legacy maintenance is unresolved; inspect the old installation before manual conversion"
|
|
35
|
+
)
|
|
36
|
+
if operation["phase"] == "accepted":
|
|
37
|
+
observed_at = time.time() if now is None else now
|
|
38
|
+
if observed_at - operation["createdAt"] < START_HANDOFF_SECONDS:
|
|
39
|
+
raise UpdateProtocolError(
|
|
40
|
+
f"Admin restart {operation['operationId']} is pending dispatch; "
|
|
41
|
+
"wait for maintenance to finish, then retry this command"
|
|
42
|
+
)
|
|
43
|
+
# The worker writes restarting before stop, while retaining this lock.
|
|
44
|
+
# Accepted + lock ownership proves that this request never began stop.
|
|
45
|
+
return advance_operation(root, operation["operationId"], "failed", "dispatch_failed")
|
|
46
|
+
# The only other nonterminal schema-3 phase is restarting. Retain unknown
|
|
47
|
+
# side effects; a succeeding explicit CLI operation may prove recovery.
|
|
48
|
+
return advance_operation(root, operation["operationId"], "recovery_required", "worker_lost")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def finish_manual_restart(root: Path, *, lock_descriptor: int, status: Any,
|
|
52
|
+
executor: Any = None) -> dict[str, Any] | None:
|
|
53
|
+
"""Reconcile a failed Admin restart after an explicit CLI ready result.
|
|
54
|
+
|
|
55
|
+
The caller still owns the root's maintenance lock and just obtained the
|
|
56
|
+
exact service manager's private ready proof. This records recovery only;
|
|
57
|
+
it never rewrites the original request as success or changes its target.
|
|
58
|
+
"""
|
|
59
|
+
from .update_executor import UpdateExecutor, UpdateExecutorError
|
|
60
|
+
import pwd
|
|
61
|
+
|
|
62
|
+
validate_inherited_lock(root, lock_descriptor)
|
|
63
|
+
if (status is None or not status.running or not status.ready
|
|
64
|
+
or status.binding.root != root):
|
|
65
|
+
raise UpdateProtocolError("manual recovery requires the exact ready instance")
|
|
66
|
+
operation = read_operation(root)
|
|
67
|
+
if (operation is None or operation.get("schema") != 3
|
|
68
|
+
or operation["phase"] != "recovery_required"):
|
|
69
|
+
return operation
|
|
70
|
+
manager = executor or UpdateExecutor(Path(pwd.getpwuid(os.geteuid()).pw_dir), root=root)
|
|
71
|
+
try:
|
|
72
|
+
# launchd retains exited jobs as loaded. Cleanup under the held lock
|
|
73
|
+
# can unload that once-only job; querying presence first would block
|
|
74
|
+
# macOS recovery forever. Linux collect needs no explicit cleanup.
|
|
75
|
+
manager.cleanup(operation["operationId"])
|
|
76
|
+
if manager.is_active(operation["operationId"]):
|
|
77
|
+
return operation
|
|
78
|
+
except UpdateExecutorError:
|
|
79
|
+
return operation
|
|
80
|
+
return advance_operation(root, operation["operationId"], "recovered", "manual_recovery")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run(root: Path, operation_id: str, *, manager: Any = None) -> int:
|
|
84
|
+
from ..cli_services import ServiceError, ServiceManager
|
|
85
|
+
from ..management.updates import installed_package
|
|
86
|
+
|
|
87
|
+
descriptor = acquire_install_lock(root, blocking=True)
|
|
88
|
+
try:
|
|
89
|
+
operation = read_operation(root)
|
|
90
|
+
if (operation is None or operation.get("schema") != 3
|
|
91
|
+
or operation["operationId"] != operation_id
|
|
92
|
+
or operation["phase"] != "accepted"):
|
|
93
|
+
return 1 # Never claim a superseded or already executed request.
|
|
94
|
+
if time.time() - operation["createdAt"] >= START_HANDOFF_SECONDS:
|
|
95
|
+
advance_operation(root, operation_id, "failed", "dispatch_failed")
|
|
96
|
+
return 1
|
|
97
|
+
current = installed_package()
|
|
98
|
+
manager = manager or ServiceManager()
|
|
99
|
+
status = manager.inspect(root)
|
|
100
|
+
if (operation["target"]["installationId"] != current.identity
|
|
101
|
+
or status is None or status.binding.python != current.python
|
|
102
|
+
or status.binding.prefix != current.prefix):
|
|
103
|
+
advance_operation(root, operation_id, "failed", "previous_release_changed")
|
|
104
|
+
return 1
|
|
105
|
+
advance_operation(root, operation_id, "restarting")
|
|
106
|
+
try:
|
|
107
|
+
manager.stop(root)
|
|
108
|
+
# start preserves the exact existing service binding and only
|
|
109
|
+
# returns after a held lifetime lock + the private ready proof.
|
|
110
|
+
manager.start(root)
|
|
111
|
+
except (ServiceError, OSError, RuntimeError):
|
|
112
|
+
advance_operation(root, operation_id, "recovery_required", "restart_failed")
|
|
113
|
+
return 1
|
|
114
|
+
advance_operation(root, operation_id, "succeeded")
|
|
115
|
+
return 0
|
|
116
|
+
finally:
|
|
117
|
+
os.close(descriptor)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> int:
|
|
121
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
122
|
+
parser.add_argument("--root", type=Path, required=True)
|
|
123
|
+
parser.add_argument("--operation-id", required=True)
|
|
124
|
+
options = parser.parse_args()
|
|
125
|
+
try:
|
|
126
|
+
return run(options.root, options.operation_id)
|
|
127
|
+
except (UpdateProtocolError, OSError, RuntimeError):
|
|
128
|
+
# Leave the durable operation for bounded Admin reconciliation. Never
|
|
129
|
+
# print manager output, credentials, or guessed success.
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Dispatch an instance restart outside the Netizen service's lifetime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..instance import instance_digest, resolve_instance_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
COMMAND_TIMEOUT_SECONDS = 10.0
|
|
17
|
+
_OPERATION_ID = re.compile(r"[0-9a-f]{32}\Z")
|
|
18
|
+
_BOOTSTRAP_PATH = "/usr/local/bin:/usr/bin:/bin"
|
|
19
|
+
_CLEARED_ENVIRONMENT = (
|
|
20
|
+
"NETIZEN_ROOT",
|
|
21
|
+
"FEISHU_APP_SECRET",
|
|
22
|
+
"NETIZEN_ADMIN_SECRET",
|
|
23
|
+
"FEISHU_APP_SECRET_FILE",
|
|
24
|
+
"NETIZEN_LARK_APP_CONFIG",
|
|
25
|
+
"NETIZEN_ADMIN_SECRET_FILE",
|
|
26
|
+
"NETIZEN_CONFIG_PATH",
|
|
27
|
+
"NETIZEN_LIFETIME_LOCK_FD",
|
|
28
|
+
"NETIZEN_LIFETIME_LOCK_FILE",
|
|
29
|
+
"NETIZEN_READY_FILE",
|
|
30
|
+
"NETIZEN_LOG_FILE",
|
|
31
|
+
"NETIZEN_MANAGED_LAUNCH_AGENT",
|
|
32
|
+
"NETIZEN_CLI_SERVICE",
|
|
33
|
+
"NETIZEN_CLI_PREFIX",
|
|
34
|
+
"NETIZEN_CLI_PYTHON",
|
|
35
|
+
"NETIZEN_UPDATE_OPERATION_ID",
|
|
36
|
+
"NETIZEN_UPDATE_LOCK_FD",
|
|
37
|
+
"NETIZEN_UPDATE_VERSION",
|
|
38
|
+
"NETIZEN_UPDATE_ARCHIVE_SHA256",
|
|
39
|
+
"PYTHONHOME",
|
|
40
|
+
"PYTHONPATH",
|
|
41
|
+
"VIRTUAL_ENV",
|
|
42
|
+
"__PYVENV_LAUNCHER__",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UpdateExecutorError(RuntimeError):
|
|
47
|
+
"""A platform operation could not be completed or safely observed."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UpdateDispatchUnknown(UpdateExecutorError):
|
|
51
|
+
"""The manager may have accepted this operation; do not dispatch it again."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UpdateExecutor:
|
|
55
|
+
def __init__(self, home: Path, platform_name: str | None = None,
|
|
56
|
+
*, root: Path | None = None) -> None:
|
|
57
|
+
if not home.is_absolute() or home == Path(home.anchor):
|
|
58
|
+
raise UpdateExecutorError("update home must be an absolute non-root path")
|
|
59
|
+
self.home = home.resolve()
|
|
60
|
+
try:
|
|
61
|
+
self.product_root = resolve_instance_root(root, account_home=self.home)
|
|
62
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
63
|
+
raise UpdateExecutorError("invalid update instance root") from error
|
|
64
|
+
self.platform_name = sys.platform if platform_name is None else platform_name
|
|
65
|
+
if self.platform_name not in {"linux", "darwin"}:
|
|
66
|
+
raise UpdateExecutorError("updates require Linux or macOS")
|
|
67
|
+
self._uid = os.geteuid()
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _validate_operation_id(operation_id: str) -> None:
|
|
71
|
+
if not isinstance(operation_id, str) or _OPERATION_ID.fullmatch(operation_id) is None:
|
|
72
|
+
raise UpdateExecutorError("invalid update operation ID")
|
|
73
|
+
|
|
74
|
+
def _label(self, operation_id: str) -> str:
|
|
75
|
+
self._validate_operation_id(operation_id)
|
|
76
|
+
return f"netizen-update-{instance_digest(self.product_root)}-{operation_id}"
|
|
77
|
+
|
|
78
|
+
def _plist_path(self, operation_id: str) -> Path:
|
|
79
|
+
return self.product_root / "state" / f"{self._label(operation_id)}.plist"
|
|
80
|
+
|
|
81
|
+
def _worker_arguments(self, operation_id: str, python: Path) -> list[str]:
|
|
82
|
+
self._validate_operation_id(operation_id)
|
|
83
|
+
if (not python.is_absolute() or not python.is_file()
|
|
84
|
+
or not os.access(python, os.X_OK)
|
|
85
|
+
or any(character in str(python) for character in "\r\n\0")):
|
|
86
|
+
raise UpdateExecutorError("the bound Python executable is unavailable")
|
|
87
|
+
return [str(python), "-E", "-P", "-B", "-u", "-m", "netizen_cli.deployment.restart_worker",
|
|
88
|
+
"--root", str(self.product_root), "--operation-id", operation_id]
|
|
89
|
+
|
|
90
|
+
def _command(
|
|
91
|
+
self, arguments: list[str], *, dispatch: bool = False, capture: bool = False
|
|
92
|
+
) -> subprocess.CompletedProcess[str]:
|
|
93
|
+
environment = dict(os.environ)
|
|
94
|
+
for key in tuple(environment):
|
|
95
|
+
if key.startswith("NETIZEN_") or key in _CLEARED_ENVIRONMENT:
|
|
96
|
+
environment.pop(key, None)
|
|
97
|
+
environment["HOME"] = str(self.home)
|
|
98
|
+
if self.platform_name == "linux":
|
|
99
|
+
environment.setdefault("XDG_RUNTIME_DIR", f"/run/user/{self._uid}")
|
|
100
|
+
environment.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{self._uid}/bus")
|
|
101
|
+
try:
|
|
102
|
+
return subprocess.run(
|
|
103
|
+
arguments,
|
|
104
|
+
stdin=subprocess.DEVNULL,
|
|
105
|
+
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
|
|
106
|
+
stderr=subprocess.DEVNULL,
|
|
107
|
+
text=True,
|
|
108
|
+
check=False,
|
|
109
|
+
close_fds=True,
|
|
110
|
+
cwd=self.home,
|
|
111
|
+
env=environment,
|
|
112
|
+
timeout=COMMAND_TIMEOUT_SECONDS,
|
|
113
|
+
)
|
|
114
|
+
except subprocess.TimeoutExpired as error:
|
|
115
|
+
exception = UpdateDispatchUnknown if dispatch else UpdateExecutorError
|
|
116
|
+
raise exception("update service manager command timed out") from error
|
|
117
|
+
except (FileNotFoundError, PermissionError) as error:
|
|
118
|
+
# Popen did not start the manager command. Its output and inherited
|
|
119
|
+
# environment are deliberately never included in user-visible errors.
|
|
120
|
+
raise UpdateExecutorError("could not execute the update service manager") from error
|
|
121
|
+
except OSError as error:
|
|
122
|
+
exception = UpdateDispatchUnknown if dispatch else UpdateExecutorError
|
|
123
|
+
raise exception("could not complete the update service manager command") from error
|
|
124
|
+
|
|
125
|
+
def launch(self, operation_id: str, python: Path) -> None:
|
|
126
|
+
"""Submit once, without waiting for the instance lock or its result.
|
|
127
|
+
|
|
128
|
+
A dispatch error after submission is ambiguous even when the manager
|
|
129
|
+
command returns nonzero. The caller must inspect the operation result
|
|
130
|
+
and execution lock; it must never retry this operation automatically.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
label = self._label(operation_id)
|
|
134
|
+
arguments = self._worker_arguments(operation_id, python)
|
|
135
|
+
if self.platform_name == "linux":
|
|
136
|
+
# systemd-run expands $ in argv, including argv[0]. A fixed env
|
|
137
|
+
# executable lets every environment path be escaped as an argument,
|
|
138
|
+
# then execs the real Python in place. Doubling argv[0] itself would
|
|
139
|
+
# make systemd-run look for a nonexistent executable. Unlike unit
|
|
140
|
+
# file syntax, transient D-Bus properties preserve literal %.
|
|
141
|
+
worker_arguments = [value.replace("$", "$$") for value in arguments]
|
|
142
|
+
result = self._command(
|
|
143
|
+
[
|
|
144
|
+
"systemd-run", "--user", "--no-ask-password", f"--unit={label}.service", "--collect",
|
|
145
|
+
"--service-type=exec", "--property=Restart=no",
|
|
146
|
+
"--property=KillMode=control-group", "--property=UMask=0077",
|
|
147
|
+
"--property=StandardInput=null", "--property=StandardOutput=null",
|
|
148
|
+
"--property=StandardError=null", f"--working-directory={self.home}",
|
|
149
|
+
f"--setenv=HOME={self.home}", f"--setenv=PATH={_BOOTSTRAP_PATH}",
|
|
150
|
+
"--property=UnsetEnvironment=" + " ".join(_CLEARED_ENVIRONMENT),
|
|
151
|
+
"--", "/usr/bin/env", "--", *worker_arguments,
|
|
152
|
+
],
|
|
153
|
+
dispatch=True,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
domain = f"gui/{self._uid}"
|
|
157
|
+
if self._command(["launchctl", "print", domain]).returncode != 0:
|
|
158
|
+
raise UpdateExecutorError("the current macOS GUI launchd domain is unavailable")
|
|
159
|
+
path = self._plist_path(operation_id)
|
|
160
|
+
self._write_plist(path, label, arguments)
|
|
161
|
+
result = self._command(["launchctl", "bootstrap", domain, str(path)], dispatch=True)
|
|
162
|
+
if result.returncode != 0:
|
|
163
|
+
raise UpdateDispatchUnknown("the update service manager did not confirm dispatch")
|
|
164
|
+
|
|
165
|
+
def _write_plist(self, path: Path, label: str, arguments: list[str]) -> None:
|
|
166
|
+
# A state-directory plist is submitted explicitly. It is not installed
|
|
167
|
+
# in Library/LaunchAgents and cannot replay an update at the next login.
|
|
168
|
+
try:
|
|
169
|
+
metadata = path.parent.lstat()
|
|
170
|
+
if (
|
|
171
|
+
not stat.S_ISDIR(metadata.st_mode)
|
|
172
|
+
or metadata.st_uid != self._uid
|
|
173
|
+
or stat.S_IMODE(metadata.st_mode) & 0o022
|
|
174
|
+
):
|
|
175
|
+
raise UpdateExecutorError("the update state directory is not private to this user")
|
|
176
|
+
payload = plistlib.dumps(
|
|
177
|
+
{
|
|
178
|
+
"Label": label,
|
|
179
|
+
"ProgramArguments": arguments,
|
|
180
|
+
"WorkingDirectory": str(self.home),
|
|
181
|
+
"RunAtLoad": True,
|
|
182
|
+
"KeepAlive": False,
|
|
183
|
+
"Umask": 0o077,
|
|
184
|
+
"EnvironmentVariables": {"HOME": str(self.home), "PATH": _BOOTSTRAP_PATH},
|
|
185
|
+
"StandardOutPath": "/dev/null",
|
|
186
|
+
"StandardErrorPath": "/dev/null",
|
|
187
|
+
},
|
|
188
|
+
sort_keys=True,
|
|
189
|
+
)
|
|
190
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
191
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
192
|
+
stream.write(payload)
|
|
193
|
+
stream.flush()
|
|
194
|
+
os.fsync(stream.fileno())
|
|
195
|
+
except FileExistsError as error:
|
|
196
|
+
raise UpdateDispatchUnknown("this update already has a manager submission file") from error
|
|
197
|
+
except OSError as error:
|
|
198
|
+
raise UpdateExecutorError("could not prepare the update LaunchAgent") from error
|
|
199
|
+
|
|
200
|
+
def is_active(self, operation_id: str) -> bool:
|
|
201
|
+
"""Return conservative manager presence, never infer absence on timeout.
|
|
202
|
+
|
|
203
|
+
launchd's print exit status proves only that a job is loaded. The caller
|
|
204
|
+
also uses the updater's execution lock to identify an exited worker.
|
|
205
|
+
No launchctl print text or process identity is parsed here.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
label = self._label(operation_id)
|
|
209
|
+
if self.platform_name == "darwin":
|
|
210
|
+
domain = f"gui/{self._uid}"
|
|
211
|
+
if self._command(["launchctl", "print", domain]).returncode != 0:
|
|
212
|
+
raise UpdateExecutorError("the current macOS GUI launchd domain is unavailable")
|
|
213
|
+
result = self._command(["launchctl", "print", f"{domain}/{label}"])
|
|
214
|
+
if result.returncode not in {0, 113}:
|
|
215
|
+
raise UpdateExecutorError("could not determine the update LaunchAgent state")
|
|
216
|
+
return result.returncode == 0
|
|
217
|
+
result = self._command(
|
|
218
|
+
[
|
|
219
|
+
"systemctl", "--user", "show", "--property=LoadState",
|
|
220
|
+
"--property=ActiveState", "--", f"{label}.service",
|
|
221
|
+
],
|
|
222
|
+
capture=True,
|
|
223
|
+
)
|
|
224
|
+
if result.returncode != 0:
|
|
225
|
+
raise UpdateExecutorError("could not determine the update service state")
|
|
226
|
+
fields = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
|
|
227
|
+
if fields.get("LoadState") == "not-found" and fields.get("ActiveState") == "inactive":
|
|
228
|
+
return False
|
|
229
|
+
if fields.get("LoadState") != "loaded":
|
|
230
|
+
raise UpdateExecutorError("the update service state is unrecognized")
|
|
231
|
+
state = fields.get("ActiveState")
|
|
232
|
+
if state in {"active", "activating", "reloading", "deactivating", "refreshing"}:
|
|
233
|
+
return True
|
|
234
|
+
if state in {"inactive", "failed"}:
|
|
235
|
+
return False
|
|
236
|
+
raise UpdateExecutorError("the update service state is unrecognized")
|
|
237
|
+
|
|
238
|
+
def cleanup(self, operation_id: str) -> None:
|
|
239
|
+
"""Remove a completed job from the service manager.
|
|
240
|
+
|
|
241
|
+
The Admin process or explicit CLI must own the instance lock and have
|
|
242
|
+
read a durable terminal result; acquiring that lock proves the worker
|
|
243
|
+
has released execution. The updater must not bootout itself: launchd
|
|
244
|
+
could kill it before it finishes writing its result.
|
|
245
|
+
Linux's --collect performs automatic transient-unit removal.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
label = self._label(operation_id)
|
|
249
|
+
if self.platform_name == "linux":
|
|
250
|
+
return
|
|
251
|
+
if self.is_active(operation_id):
|
|
252
|
+
result = self._command(["launchctl", "bootout", f"gui/{self._uid}/{label}"])
|
|
253
|
+
if result.returncode != 0 and self.is_active(operation_id):
|
|
254
|
+
raise UpdateExecutorError("could not remove the completed update LaunchAgent")
|
|
255
|
+
try:
|
|
256
|
+
self._plist_path(operation_id).unlink(missing_ok=True)
|
|
257
|
+
except OSError as error:
|
|
258
|
+
raise UpdateExecutorError("could not remove the completed update submission file") from error
|