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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. 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