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,651 @@
|
|
|
1
|
+
"""Per-user installed-CLI services; definitions are the instance inventory.
|
|
2
|
+
|
|
3
|
+
Only Netizen's CLI v1 service definition is writable, rendered for systemd or
|
|
4
|
+
launchd. Recognition uses the same platform-specific contract independently
|
|
5
|
+
of the registration renderer.
|
|
6
|
+
systemd state comes from machine properties; launchd's documented list columns
|
|
7
|
+
provide PID state.
|
|
8
|
+
The diagnostic output of ``launchctl print`` is deliberately never parsed.
|
|
9
|
+
Callers serialize lifecycle mutations with the instance maintenance lock.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fcntl
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import plistlib
|
|
18
|
+
import pwd
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .instance import launch_agent_label, require_instance_root_marker, systemd_service_name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ServiceError(RuntimeError):
|
|
31
|
+
"""Service identity, manager state, or lifecycle completion is unproven."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ServiceBinding:
|
|
36
|
+
root: Path
|
|
37
|
+
python: Path
|
|
38
|
+
prefix: Path
|
|
39
|
+
codex_home: Path | None = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
paths = (self.root, self.python, self.prefix)
|
|
43
|
+
if self.codex_home is not None:
|
|
44
|
+
paths += (self.codex_home,)
|
|
45
|
+
for path in paths:
|
|
46
|
+
if not path.is_absolute() or any(ord(c) < 32 for c in str(path)):
|
|
47
|
+
raise ServiceError("service binding paths must be absolute without control characters")
|
|
48
|
+
if self.root != self.root.resolve() or self.prefix != self.prefix.resolve():
|
|
49
|
+
raise ServiceError("instance root and environment prefix must be canonical")
|
|
50
|
+
# Do NOT resolve python: two venvs often point at the same base binary.
|
|
51
|
+
if self.python != Path(os.path.abspath(self.python)):
|
|
52
|
+
raise ServiceError("Python launch path must be normalized without resolving its symlink")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ServiceStatus:
|
|
57
|
+
binding: ServiceBinding
|
|
58
|
+
running: bool
|
|
59
|
+
enabled: bool | None
|
|
60
|
+
ready: bool
|
|
61
|
+
loaded: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_SERVICE_MARKER = "# Netizen CLI service v1 "
|
|
65
|
+
_LAUNCH_AGENT_SENTINEL = "io.github.lijingda.netizen/cli-v1"
|
|
66
|
+
_READY = b"netizen service ready\n"
|
|
67
|
+
_LOG_READ_LIMIT = 1024 * 1024
|
|
68
|
+
_SYSTEMD_PROPERTIES = (
|
|
69
|
+
"LoadState", "ActiveState", "SubState", "MainPID", "ControlPID", "FragmentPath",
|
|
70
|
+
"DropInPaths", "NeedDaemonReload", "Transient", "UnitFileState",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _systemd_quote(value: str) -> str:
|
|
75
|
+
if any(ord(c) < 32 for c in value):
|
|
76
|
+
raise ServiceError("systemd values cannot contain control characters")
|
|
77
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _service_environment(home: Path, binding: ServiceBinding) -> dict[str, str]:
|
|
81
|
+
"""Service environment: no extra overrides or caller PATH are accepted."""
|
|
82
|
+
root = binding.root
|
|
83
|
+
environment = {
|
|
84
|
+
"HOME": str(home), "NETIZEN_ROOT": str(root),
|
|
85
|
+
"NETIZEN_CLI_PREFIX": str(binding.prefix), "NETIZEN_CLI_SERVICE": "1",
|
|
86
|
+
"NETIZEN_CLI_PYTHON": str(binding.python),
|
|
87
|
+
"NETIZEN_CONFIG_PATH": str(root / "config.yaml"),
|
|
88
|
+
"NETIZEN_LARK_APP_CONFIG": str(root / "lark-app/config.json"),
|
|
89
|
+
"NETIZEN_ADMIN_SECRET_FILE": str(root / "credentials/admin-web-secret"),
|
|
90
|
+
"NETIZEN_READY_FILE": str(root / "state/service.ready"),
|
|
91
|
+
"NETIZEN_LIFETIME_LOCK_FILE": str(root / "state/service.lifetime.lock"),
|
|
92
|
+
"NETIZEN_LOG_FILE": str(root / "state/netizen.log"),
|
|
93
|
+
"PATH": ":".join([str(home / ".local/bin"), "/opt/homebrew/bin",
|
|
94
|
+
"/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
|
|
95
|
+
}
|
|
96
|
+
if binding.codex_home is not None:
|
|
97
|
+
environment["CODEX_HOME"] = str(binding.codex_home)
|
|
98
|
+
return environment
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _service_arguments(binding: ServiceBinding) -> list[str]:
|
|
102
|
+
return [str(binding.python), "-E", "-P", "-m", "netizen_cli", "_serve",
|
|
103
|
+
"--root", str(binding.root)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _service_definition(home: Path, platform: str, binding: ServiceBinding,
|
|
107
|
+
*, enabled: bool = True) -> bytes:
|
|
108
|
+
"""CLI v1 contract with native platform serialization.
|
|
109
|
+
|
|
110
|
+
Linux accepts this exact serialization; launchd accepts this exact plist
|
|
111
|
+
structure and types. Binding paths and RunAtLoad are the only variable
|
|
112
|
+
fields. This is not a parser for arbitrary manager overrides.
|
|
113
|
+
"""
|
|
114
|
+
env = _service_environment(home, binding)
|
|
115
|
+
if platform == "darwin":
|
|
116
|
+
env["NETIZEN_MANAGED_LAUNCH_AGENT"] = _LAUNCH_AGENT_SENTINEL
|
|
117
|
+
return plistlib.dumps({
|
|
118
|
+
"Label": launch_agent_label(binding.root),
|
|
119
|
+
"ProgramArguments": _service_arguments(binding),
|
|
120
|
+
"WorkingDirectory": str(home), "RunAtLoad": enabled,
|
|
121
|
+
"KeepAlive": {"SuccessfulExit": False}, "ExitTimeOut": 75,
|
|
122
|
+
"ThrottleInterval": 3, "Umask": 0o077,
|
|
123
|
+
"AbandonProcessGroup": False, "EnvironmentVariables": env,
|
|
124
|
+
"StandardOutPath": "/dev/null",
|
|
125
|
+
"StandardErrorPath": str(binding.root / "state/launchd.stderr.log"),
|
|
126
|
+
}, sort_keys=True)
|
|
127
|
+
metadata = json.dumps({"root": str(binding.root), "python": str(binding.python),
|
|
128
|
+
"prefix": str(binding.prefix),
|
|
129
|
+
"codex_home": str(binding.codex_home) if binding.codex_home else None},
|
|
130
|
+
sort_keys=True)
|
|
131
|
+
lines = [
|
|
132
|
+
_SERVICE_MARKER + metadata, "[Unit]", "Description=Netizen CLI instance",
|
|
133
|
+
"After=network-online.target", "[Service]", "Type=simple",
|
|
134
|
+
# This single-path field does not unquote or C-unescape. Escape only
|
|
135
|
+
# specifiers; '/' protects a trailing space or backslash in the path.
|
|
136
|
+
"WorkingDirectory=" + str(home).replace("%", "%%") + "/",
|
|
137
|
+
"ExecStart=:" + " ".join(_systemd_quote(arg) for arg in _service_arguments(binding)),
|
|
138
|
+
"Restart=on-failure", "RestartSec=3", "TimeoutStopSec=75",
|
|
139
|
+
"KillMode=control-group", "UMask=0077",
|
|
140
|
+
*["Environment=" + _systemd_quote(f"{key}={value}") for key, value in env.items()],
|
|
141
|
+
"[Install]", "WantedBy=default.target", "",
|
|
142
|
+
]
|
|
143
|
+
return "\n".join(lines).encode()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _same_service_fragment(fragment: str, expected: Path) -> bool:
|
|
147
|
+
"""Allow parent-directory aliases, never a different or symlink unit file."""
|
|
148
|
+
actual = Path(fragment)
|
|
149
|
+
try:
|
|
150
|
+
return (actual.is_absolute() and not actual.is_symlink()
|
|
151
|
+
and actual.resolve(strict=True) == expected.resolve(strict=True))
|
|
152
|
+
except (OSError, RuntimeError, ValueError):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_owned(path: Path, uid: int) -> bytes:
|
|
157
|
+
try:
|
|
158
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
|
|
159
|
+
with os.fdopen(fd, "rb") as source:
|
|
160
|
+
metadata = os.fstat(source.fileno())
|
|
161
|
+
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != uid
|
|
162
|
+
or stat.S_IMODE(metadata.st_mode) & 0o022):
|
|
163
|
+
raise ServiceError(f"not a protected current-user regular file: {path}")
|
|
164
|
+
return source.read()
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
raise ServiceError(f"cannot read owned service file {path}: {exc}") from exc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _lock_available(root: Path, uid: int) -> bool:
|
|
170
|
+
path = root / "state" / "service.lifetime.lock"
|
|
171
|
+
try:
|
|
172
|
+
fd = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
return True
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise ServiceError(f"cannot inspect instance lifetime lock {path}: {exc}") from exc
|
|
177
|
+
try:
|
|
178
|
+
metadata = os.fstat(fd)
|
|
179
|
+
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != uid
|
|
180
|
+
or stat.S_IMODE(metadata.st_mode) != 0o600):
|
|
181
|
+
raise ServiceError(f"unsafe instance lifetime lock: {path}")
|
|
182
|
+
try:
|
|
183
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
184
|
+
except BlockingIOError:
|
|
185
|
+
return False
|
|
186
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
187
|
+
return True
|
|
188
|
+
finally:
|
|
189
|
+
os.close(fd)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tail_log(path: Path, *, uid: int, lines: int) -> str | None:
|
|
193
|
+
"""Read a bounded tail without following a log file replaced by a symlink."""
|
|
194
|
+
try:
|
|
195
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return None
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
raise ServiceError(f"cannot read log {path}: {exc}") from exc
|
|
200
|
+
with os.fdopen(fd, "rb") as source:
|
|
201
|
+
metadata = os.fstat(source.fileno())
|
|
202
|
+
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != uid
|
|
203
|
+
or stat.S_IMODE(metadata.st_mode) & 0o022):
|
|
204
|
+
raise ServiceError(f"not a protected current-user log file: {path}")
|
|
205
|
+
offset = max(0, metadata.st_size - _LOG_READ_LIMIT)
|
|
206
|
+
source.seek(offset)
|
|
207
|
+
payload = source.read(_LOG_READ_LIMIT)
|
|
208
|
+
if offset:
|
|
209
|
+
# Drop a possibly partial first line, but retain a giant last line's
|
|
210
|
+
# bounded bytes if it contains no newline at all.
|
|
211
|
+
separator = payload.find(b"\n")
|
|
212
|
+
if separator >= 0:
|
|
213
|
+
payload = payload[separator + 1:]
|
|
214
|
+
text = "\n".join(payload.decode("utf-8", errors="replace").splitlines()[-lines:])
|
|
215
|
+
return ("[earlier log bytes omitted]\n" if offset else "") + text
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class ServiceManager:
|
|
219
|
+
def __init__(
|
|
220
|
+
self, *, home: Path | None = None, platform: str | None = None,
|
|
221
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
222
|
+
) -> None:
|
|
223
|
+
self.uid = os.geteuid()
|
|
224
|
+
self.home = (home or Path(pwd.getpwuid(self.uid).pw_dir)).resolve()
|
|
225
|
+
self.platform = platform or sys.platform
|
|
226
|
+
if self.platform not in {"linux", "darwin"}:
|
|
227
|
+
raise ServiceError("Netizen supports systemd user services and macOS GUI LaunchAgents only")
|
|
228
|
+
self.service_dir = self.home / (
|
|
229
|
+
".config/systemd/user" if self.platform == "linux" else "Library/LaunchAgents"
|
|
230
|
+
)
|
|
231
|
+
self._runner = runner
|
|
232
|
+
|
|
233
|
+
def service_name(self, root: Path) -> str:
|
|
234
|
+
return (systemd_service_name if self.platform == "linux" else launch_agent_label)(root)
|
|
235
|
+
|
|
236
|
+
def service_file(self, root: Path) -> Path:
|
|
237
|
+
name = self.service_name(root)
|
|
238
|
+
return self.service_dir / (name if self.platform == "linux" else name + ".plist")
|
|
239
|
+
|
|
240
|
+
def _instance_name(self, name: str) -> bool:
|
|
241
|
+
prefix = "netizen-" if self.platform == "linux" else "io.github.lijingda.netizen."
|
|
242
|
+
suffix = ".service" if self.platform == "linux" else ""
|
|
243
|
+
if not name.startswith(prefix) or (suffix and not name.endswith(suffix)):
|
|
244
|
+
return False
|
|
245
|
+
digest = name[len(prefix):len(name) - len(suffix) if suffix else None]
|
|
246
|
+
return len(digest) == 24 and all(c in "0123456789abcdef" for c in digest)
|
|
247
|
+
|
|
248
|
+
def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
249
|
+
env = {key: value for key, value in os.environ.items()
|
|
250
|
+
if not key.startswith(("PYTHON", "LD_", "DYLD_"))}
|
|
251
|
+
env["HOME"] = str(self.home)
|
|
252
|
+
env["LC_ALL"] = "C"
|
|
253
|
+
if self.platform == "linux":
|
|
254
|
+
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{self.uid}")
|
|
255
|
+
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{self.uid}/bus")
|
|
256
|
+
try:
|
|
257
|
+
result = self._runner(list(args), check=False, capture_output=True, text=True,
|
|
258
|
+
env=env, timeout=100)
|
|
259
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
260
|
+
raise ServiceError(f"service manager command failed: {args[0]}: {exc}") from exc
|
|
261
|
+
if check and result.returncode:
|
|
262
|
+
raise ServiceError(f"service manager command failed ({result.returncode}): "
|
|
263
|
+
f"{' '.join(args)}; {result.stderr.strip()}")
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
def _systemctl(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
267
|
+
return self._run("systemctl", "--user", *args, check=check)
|
|
268
|
+
|
|
269
|
+
def _launchctl(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
270
|
+
return self._run("launchctl", *args, check=check)
|
|
271
|
+
|
|
272
|
+
def _root(self, root: Path) -> Path:
|
|
273
|
+
root = Path(root).resolve()
|
|
274
|
+
if root in {self.home, Path(root.anchor)}:
|
|
275
|
+
raise ServiceError("instance root cannot be the account home or filesystem root")
|
|
276
|
+
return root
|
|
277
|
+
|
|
278
|
+
def _render(self, binding: ServiceBinding, *, enabled: bool = True) -> bytes:
|
|
279
|
+
return _service_definition(self.home, self.platform, binding, enabled=enabled)
|
|
280
|
+
|
|
281
|
+
def _binding_from_file(self, path: Path) -> ServiceBinding:
|
|
282
|
+
content = _read_owned(path, self.uid)
|
|
283
|
+
try:
|
|
284
|
+
if self.platform == "linux":
|
|
285
|
+
first, *_ = content.decode().splitlines()
|
|
286
|
+
if not first.startswith(_SERVICE_MARKER):
|
|
287
|
+
raise ValueError("unsupported service definition format; only CLI v1 is supported")
|
|
288
|
+
raw = json.loads(first[len(_SERVICE_MARKER):])
|
|
289
|
+
if set(raw) != {"root", "python", "prefix", "codex_home"}:
|
|
290
|
+
raise ValueError("unrecognized binding metadata")
|
|
291
|
+
binding = ServiceBinding(*(Path(raw[key]) for key in ("root", "python", "prefix")),
|
|
292
|
+
Path(raw["codex_home"]) if raw["codex_home"] is not None else None)
|
|
293
|
+
expected = _service_definition(self.home, self.platform, binding)
|
|
294
|
+
valid = content == expected
|
|
295
|
+
else:
|
|
296
|
+
payload = plistlib.loads(content)
|
|
297
|
+
env = payload["EnvironmentVariables"]
|
|
298
|
+
if not isinstance(env, dict) or env.get("NETIZEN_MANAGED_LAUNCH_AGENT") != _LAUNCH_AGENT_SENTINEL:
|
|
299
|
+
raise ValueError("unsupported service definition format; only CLI v1 is supported")
|
|
300
|
+
binding = ServiceBinding(Path(env["NETIZEN_ROOT"]),
|
|
301
|
+
Path(env["NETIZEN_CLI_PYTHON"]),
|
|
302
|
+
Path(env["NETIZEN_CLI_PREFIX"]),
|
|
303
|
+
Path(env["CODEX_HOME"]) if "CODEX_HOME" in env else None)
|
|
304
|
+
enabled = payload["RunAtLoad"]
|
|
305
|
+
expected = plistlib.loads(_service_definition(self.home, self.platform, binding, enabled=enabled))
|
|
306
|
+
# plist serialization retains scalar types (False is not the
|
|
307
|
+
# integer 0), unlike Python's ordinary dictionary equality.
|
|
308
|
+
valid = (isinstance(enabled, bool)
|
|
309
|
+
and plistlib.dumps(payload, sort_keys=True)
|
|
310
|
+
== plistlib.dumps(expected, sort_keys=True))
|
|
311
|
+
except (ValueError, TypeError, KeyError, IndexError, OverflowError, UnicodeError) as exc:
|
|
312
|
+
raise ServiceError(f"unrecognized service definition {path}: {exc}") from exc
|
|
313
|
+
if not valid or path != self.service_file(binding.root):
|
|
314
|
+
raise ServiceError(f"modified or mismatched service definition: {path}")
|
|
315
|
+
if binding.root == self.home or binding.root == Path(binding.root.anchor):
|
|
316
|
+
raise ServiceError(f"unsafe root in service definition: {path}")
|
|
317
|
+
return binding
|
|
318
|
+
|
|
319
|
+
def _systemd_properties(self, root: Path) -> dict[str, str]:
|
|
320
|
+
# A failed query is never interpreted as an absent service.
|
|
321
|
+
result = self._systemctl("show", self.service_name(root),
|
|
322
|
+
"--property=" + ",".join(_SYSTEMD_PROPERTIES), check=False)
|
|
323
|
+
props: dict[str, str] = {}
|
|
324
|
+
for line in result.stdout.splitlines():
|
|
325
|
+
key, sep, value = line.partition("=")
|
|
326
|
+
if not sep or key not in _SYSTEMD_PROPERTIES or key in props:
|
|
327
|
+
raise ServiceError("unrecognized systemctl show property response")
|
|
328
|
+
props[key] = value
|
|
329
|
+
if set(props) != set(_SYSTEMD_PROPERTIES):
|
|
330
|
+
raise ServiceError("systemd state unavailable or incomplete; no service action performed")
|
|
331
|
+
if result.returncode and props.get("LoadState") != "not-found":
|
|
332
|
+
raise ServiceError(f"systemd service query failed ({result.returncode})")
|
|
333
|
+
return props
|
|
334
|
+
|
|
335
|
+
def _launchd_jobs(self) -> dict[str, int | None]:
|
|
336
|
+
self._launchctl("print", f"gui/{self.uid}") # exit code only
|
|
337
|
+
output = self._launchctl("list").stdout
|
|
338
|
+
lines = output.splitlines()
|
|
339
|
+
if not lines or lines[0].split() != ["PID", "Status", "Label"]:
|
|
340
|
+
raise ServiceError("unsupported launchctl list format; refusing ambiguous service state")
|
|
341
|
+
jobs: dict[str, int | None] = {}
|
|
342
|
+
for line in lines[1:]:
|
|
343
|
+
parts = line.split()
|
|
344
|
+
if len(parts) != 3:
|
|
345
|
+
raise ServiceError("malformed launchctl list row")
|
|
346
|
+
pid, status_code, label = parts
|
|
347
|
+
if (not status_code.lstrip("-").isdigit() or label in jobs
|
|
348
|
+
or (pid != "-" and (not pid.isdigit() or int(pid) <= 0))):
|
|
349
|
+
raise ServiceError("invalid launchctl list PID/status/label")
|
|
350
|
+
jobs[label] = None if pid == "-" else int(pid)
|
|
351
|
+
return jobs
|
|
352
|
+
|
|
353
|
+
def _ready(self, root: Path) -> bool:
|
|
354
|
+
path = root / "state/service.ready"
|
|
355
|
+
if not path.exists() and not path.is_symlink():
|
|
356
|
+
return False
|
|
357
|
+
try:
|
|
358
|
+
return stat.S_IMODE(path.lstat().st_mode) == 0o600 and _read_owned(path, self.uid) == _READY
|
|
359
|
+
except (OSError, ServiceError):
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def _clear_ready(self, root: Path) -> None:
|
|
363
|
+
path = root / "state/service.ready"
|
|
364
|
+
if not path.exists() and not path.is_symlink():
|
|
365
|
+
return
|
|
366
|
+
_read_owned(path, self.uid)
|
|
367
|
+
path.unlink()
|
|
368
|
+
|
|
369
|
+
def inspect(self, root: Path, *, lifetime_descriptor: int | None = None) -> ServiceStatus | None:
|
|
370
|
+
return self._inspect(root, lifetime_descriptor=lifetime_descriptor)
|
|
371
|
+
|
|
372
|
+
def _identity_matches(self, binding: ServiceBinding, pid: int, *, starting: bool = False) -> bool:
|
|
373
|
+
path = binding.root / "state/service.identity.json"
|
|
374
|
+
if not path.exists() and not path.is_symlink():
|
|
375
|
+
return False
|
|
376
|
+
try:
|
|
377
|
+
if stat.S_IMODE(path.lstat().st_mode) != 0o600:
|
|
378
|
+
raise ValueError("runtime identity is not private")
|
|
379
|
+
identity = json.loads(_read_owned(path, self.uid))
|
|
380
|
+
except (OSError, ValueError) as exc:
|
|
381
|
+
raise ServiceError(f"invalid runtime service identity: {path}: {exc}") from exc
|
|
382
|
+
expected = {"format": 1, "pid": pid, "python": str(binding.python),
|
|
383
|
+
"prefix": str(binding.prefix), "root": str(binding.root)}
|
|
384
|
+
if (starting and isinstance(identity, dict) and type(identity.get("pid")) is int
|
|
385
|
+
and identity["pid"] != pid):
|
|
386
|
+
# The new launcher can briefly own the lock before replacing the
|
|
387
|
+
# previous process's evidence. This is not a ready or update proof.
|
|
388
|
+
return False
|
|
389
|
+
if (identity != expected or type(identity.get("format")) is not int
|
|
390
|
+
or type(identity.get("pid")) is not int):
|
|
391
|
+
raise ServiceError("running service identity differs from its registered binding; "
|
|
392
|
+
"inspect the service definition before controlling it")
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
def _inspect(self, root: Path, *, stopping: bool = False,
|
|
396
|
+
starting: bool = False,
|
|
397
|
+
lifetime_descriptor: int | None = None) -> ServiceStatus | None:
|
|
398
|
+
root = self._root(root)
|
|
399
|
+
path = self.service_file(root)
|
|
400
|
+
exists = path.exists() or path.is_symlink()
|
|
401
|
+
binding = self._binding_from_file(path) if exists else None
|
|
402
|
+
if binding is not None and binding.root != root:
|
|
403
|
+
raise ServiceError("service root does not match selected instance")
|
|
404
|
+
if self.platform == "linux":
|
|
405
|
+
props = self._systemd_properties(root)
|
|
406
|
+
if binding is None:
|
|
407
|
+
if props["LoadState"] != "not-found" or props["FragmentPath"]:
|
|
408
|
+
raise ServiceError("service definition is missing but systemd still knows the target")
|
|
409
|
+
running = False
|
|
410
|
+
else:
|
|
411
|
+
if (props["LoadState"] != "loaded"
|
|
412
|
+
or not _same_service_fragment(props["FragmentPath"], path)
|
|
413
|
+
or props["DropInPaths"] or props["NeedDaemonReload"] != "no"
|
|
414
|
+
or props["Transient"] != "no"):
|
|
415
|
+
raise ServiceError("effective systemd definition differs or is unknown; inspect overrides")
|
|
416
|
+
active = props["ActiveState"]
|
|
417
|
+
if active not in {"active", "activating", "reloading", "deactivating", "inactive", "failed"}:
|
|
418
|
+
raise ServiceError(f"unrecognized systemd active state: {active}")
|
|
419
|
+
if not props["MainPID"].isdigit() or not props["ControlPID"].isdigit():
|
|
420
|
+
raise ServiceError("systemd did not report exact process state")
|
|
421
|
+
running = active not in {"inactive", "failed"} or any(
|
|
422
|
+
int(props[key]) > 0 for key in ("MainPID", "ControlPID")
|
|
423
|
+
)
|
|
424
|
+
unit_state = props["UnitFileState"]
|
|
425
|
+
enabled = (True if unit_state in {"enabled", "enabled-runtime"}
|
|
426
|
+
else False if unit_state == "disabled" else None)
|
|
427
|
+
loaded = props["LoadState"] == "loaded"
|
|
428
|
+
else:
|
|
429
|
+
jobs = self._launchd_jobs()
|
|
430
|
+
name = self.service_name(root)
|
|
431
|
+
exact = self._launchctl("print", f"gui/{self.uid}/{name}", check=False)
|
|
432
|
+
loaded = name in jobs
|
|
433
|
+
if (exact.returncode == 0) != loaded:
|
|
434
|
+
raise ServiceError("launchctl namespace/state disagrees with the exact GUI target")
|
|
435
|
+
if binding is None and loaded:
|
|
436
|
+
raise ServiceError("LaunchAgent definition missing but target is still loaded")
|
|
437
|
+
running = loaded and jobs[name] is not None
|
|
438
|
+
# print-disabled is diagnostic text, not a machine API. Do not guess.
|
|
439
|
+
enabled = None
|
|
440
|
+
lock_free = _lock_available(root, self.uid)
|
|
441
|
+
if lifetime_descriptor is not None:
|
|
442
|
+
from .cli_data import validate_lifetime_lock
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
validate_lifetime_lock(root, lifetime_descriptor)
|
|
446
|
+
except (OSError, RuntimeError) as exc:
|
|
447
|
+
raise ServiceError(f"caller does not hold this instance's lifetime lock: {exc}") from exc
|
|
448
|
+
if running:
|
|
449
|
+
raise ServiceError("manager reports a running instance while caller owns its lifetime lock")
|
|
450
|
+
lock_free = True # Exact ownership, not mere observation of a busy inode.
|
|
451
|
+
if not running and not lock_free and not stopping:
|
|
452
|
+
raise ServiceError("instance lifetime lock is held despite a stopped/unbound service")
|
|
453
|
+
if binding is None:
|
|
454
|
+
return None
|
|
455
|
+
identity_proven = True
|
|
456
|
+
if self.platform == "darwin" and running and not stopping:
|
|
457
|
+
identity_proven = not lock_free and self._identity_matches(binding, jobs[name], starting=starting)
|
|
458
|
+
if not identity_proven and not starting:
|
|
459
|
+
raise ServiceError("running LaunchAgent identity is not yet proven by its lifetime lock "
|
|
460
|
+
"and runtime identity; retry after startup")
|
|
461
|
+
return ServiceStatus(binding, running, enabled,
|
|
462
|
+
running and not lock_free and identity_proven and self._ready(root), loaded)
|
|
463
|
+
|
|
464
|
+
def list_instances(self, *, prefix: Path | None = None) -> list[ServiceStatus]:
|
|
465
|
+
"""Inspect all owned candidates before a caller can begin stopping any."""
|
|
466
|
+
pattern = "netizen*.service" if self.platform == "linux" else "io.github.lijingda.netizen*.plist"
|
|
467
|
+
candidates = {path for path in self.service_dir.glob(pattern)
|
|
468
|
+
if self._instance_name(path.name if self.platform == "linux" else path.stem)}
|
|
469
|
+
if self.platform == "linux":
|
|
470
|
+
for command, key in (("list-units", "unit"), ("list-unit-files", "unit_file")):
|
|
471
|
+
args = [command, "netizen*.service", "--output=json", "--no-pager"]
|
|
472
|
+
if command == "list-units":
|
|
473
|
+
args.append("--all")
|
|
474
|
+
try:
|
|
475
|
+
result = self._systemctl(*args, check=False)
|
|
476
|
+
rows = json.loads(result.stdout)
|
|
477
|
+
if not isinstance(rows, list):
|
|
478
|
+
raise ValueError("expected service array")
|
|
479
|
+
# systemd 252's list-unit-files returns 1 for no matches.
|
|
480
|
+
# Only its validated empty JSON array has this meaning;
|
|
481
|
+
# no diagnostic text or other failed query proves absence.
|
|
482
|
+
if result.returncode and not (command == "list-unit-files"
|
|
483
|
+
and result.returncode == 1 and not rows
|
|
484
|
+
and not result.stderr.strip()):
|
|
485
|
+
raise ServiceError(f"systemd inventory query failed ({result.returncode})")
|
|
486
|
+
for row in rows:
|
|
487
|
+
name = row[key]
|
|
488
|
+
if not isinstance(name, str) or Path(name).name != name:
|
|
489
|
+
raise ValueError("invalid unit name")
|
|
490
|
+
if self._instance_name(name):
|
|
491
|
+
candidates.add(self.service_dir / name)
|
|
492
|
+
except (ValueError, TypeError, KeyError) as exc:
|
|
493
|
+
raise ServiceError("unsupported systemd service inventory response") from exc
|
|
494
|
+
else:
|
|
495
|
+
for name in self._launchd_jobs():
|
|
496
|
+
if self._instance_name(name):
|
|
497
|
+
candidates.add(self.service_dir / (name + ".plist"))
|
|
498
|
+
statuses = []
|
|
499
|
+
for path in sorted(candidates):
|
|
500
|
+
binding = self._binding_from_file(path)
|
|
501
|
+
if prefix is not None and binding.prefix != prefix.resolve():
|
|
502
|
+
continue
|
|
503
|
+
status = self.inspect(binding.root)
|
|
504
|
+
if status is None:
|
|
505
|
+
raise ServiceError("service inventory changed while being inspected")
|
|
506
|
+
statuses.append(status)
|
|
507
|
+
return statuses
|
|
508
|
+
|
|
509
|
+
def register(self, binding: ServiceBinding, *, enabled: bool = True,
|
|
510
|
+
lifetime_descriptor: int | None = None) -> None:
|
|
511
|
+
self._root(binding.root)
|
|
512
|
+
try:
|
|
513
|
+
require_instance_root_marker(binding.root, uid=self.uid)
|
|
514
|
+
except (OSError, ValueError) as exc:
|
|
515
|
+
raise ServiceError(f"instance must be prepared before service registration: {exc}") from exc
|
|
516
|
+
existing = self.inspect(binding.root, lifetime_descriptor=lifetime_descriptor)
|
|
517
|
+
if existing is not None:
|
|
518
|
+
if existing.binding != binding:
|
|
519
|
+
raise ServiceError("instance already bound to another environment; remove first")
|
|
520
|
+
return
|
|
521
|
+
if not binding.python.is_file() or not binding.prefix.is_dir():
|
|
522
|
+
raise ServiceError("bound Python environment is missing; select an installed environment")
|
|
523
|
+
self.service_dir.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
if self.service_dir.is_symlink():
|
|
525
|
+
raise ServiceError("service directory must not be a symlink")
|
|
526
|
+
metadata = self.service_dir.stat()
|
|
527
|
+
if metadata.st_uid != self.uid or stat.S_IMODE(metadata.st_mode) & 0o022:
|
|
528
|
+
raise ServiceError("service directory must be owned by the current user and not group/world writable")
|
|
529
|
+
path = self.service_file(binding.root)
|
|
530
|
+
try:
|
|
531
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, 0o600)
|
|
532
|
+
with os.fdopen(fd, "wb") as dest:
|
|
533
|
+
dest.write(self._render(binding, enabled=enabled))
|
|
534
|
+
dest.flush()
|
|
535
|
+
os.fsync(dest.fileno())
|
|
536
|
+
except OSError as exc:
|
|
537
|
+
raise ServiceError(f"could not register service definition {path}: {exc}") from exc
|
|
538
|
+
if self.platform == "linux":
|
|
539
|
+
self._systemctl("daemon-reload")
|
|
540
|
+
self._systemctl("enable" if enabled else "disable", self.service_name(binding.root))
|
|
541
|
+
else:
|
|
542
|
+
self._run("plutil", "-lint", str(path))
|
|
543
|
+
self._launchctl("enable" if enabled else "disable",
|
|
544
|
+
f"gui/{self.uid}/{self.service_name(binding.root)}")
|
|
545
|
+
self.inspect(binding.root, lifetime_descriptor=lifetime_descriptor)
|
|
546
|
+
|
|
547
|
+
def start(self, root: Path, *, timeout: float = 120) -> ServiceStatus:
|
|
548
|
+
root = self._root(root)
|
|
549
|
+
status = self._inspect(root, starting=True)
|
|
550
|
+
if status is None:
|
|
551
|
+
raise ServiceError("instance has no service binding; register it before starting")
|
|
552
|
+
if status.ready:
|
|
553
|
+
return status
|
|
554
|
+
if not status.binding.python.is_file() or not status.binding.prefix.is_dir():
|
|
555
|
+
raise ServiceError("bound Python environment is missing; remove and start from the new environment")
|
|
556
|
+
if not status.running:
|
|
557
|
+
self._clear_ready(root)
|
|
558
|
+
if self.platform == "linux":
|
|
559
|
+
self._systemctl("start", self.service_name(root))
|
|
560
|
+
elif not status.loaded:
|
|
561
|
+
# Do not enable: a pre-existing disable override must remain visible.
|
|
562
|
+
self._launchctl("bootstrap", f"gui/{self.uid}", str(self.service_file(root)))
|
|
563
|
+
else:
|
|
564
|
+
self._launchctl("kickstart", f"gui/{self.uid}/{self.service_name(root)}")
|
|
565
|
+
deadline = time.monotonic() + timeout
|
|
566
|
+
while True:
|
|
567
|
+
current = self._inspect(root, starting=True)
|
|
568
|
+
if current is None:
|
|
569
|
+
raise ServiceError("service was removed while starting")
|
|
570
|
+
if current.ready:
|
|
571
|
+
return current
|
|
572
|
+
if time.monotonic() >= deadline:
|
|
573
|
+
raise ServiceError(f"service did not become ready within {timeout:g}s; "
|
|
574
|
+
"inspect status/logs; startup failure does not imply it is stopped")
|
|
575
|
+
time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
|
|
576
|
+
|
|
577
|
+
def stop(self, root: Path, *, timeout: float = 90) -> ServiceStatus:
|
|
578
|
+
root = self._root(root)
|
|
579
|
+
status = self.inspect(root)
|
|
580
|
+
if status is None:
|
|
581
|
+
raise ServiceError("instance has no registered service to stop")
|
|
582
|
+
if self.platform == "linux":
|
|
583
|
+
# stop cancels pending restarts without changing boot-time enable intent.
|
|
584
|
+
self._systemctl("stop", self.service_name(root))
|
|
585
|
+
elif status.loaded:
|
|
586
|
+
self._launchctl("bootout", f"gui/{self.uid}/{self.service_name(root)}")
|
|
587
|
+
deadline = time.monotonic() + timeout
|
|
588
|
+
while True:
|
|
589
|
+
current = self._inspect(root, stopping=True)
|
|
590
|
+
if current is None:
|
|
591
|
+
raise ServiceError("service disappeared during stop confirmation")
|
|
592
|
+
if not current.running and _lock_available(root, self.uid):
|
|
593
|
+
if self.platform == "darwin" and current.loaded:
|
|
594
|
+
raise ServiceError("launchd target remained loaded after bootout")
|
|
595
|
+
self._clear_ready(root)
|
|
596
|
+
return current
|
|
597
|
+
if time.monotonic() >= deadline:
|
|
598
|
+
raise ServiceError(f"service did not fully exit within {timeout:g}s; no further action performed")
|
|
599
|
+
time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
|
|
600
|
+
|
|
601
|
+
def restart(self, root: Path) -> ServiceStatus:
|
|
602
|
+
self.stop(root)
|
|
603
|
+
return self.start(root)
|
|
604
|
+
|
|
605
|
+
def remove(self, root: Path) -> None:
|
|
606
|
+
root = self._root(root)
|
|
607
|
+
if self.inspect(root) is None:
|
|
608
|
+
return
|
|
609
|
+
self.stop(root)
|
|
610
|
+
path = self.service_file(root)
|
|
611
|
+
self._binding_from_file(path)
|
|
612
|
+
if self.platform == "linux":
|
|
613
|
+
self._systemctl("disable", self.service_name(root))
|
|
614
|
+
else:
|
|
615
|
+
self._launchctl("disable", f"gui/{self.uid}/{self.service_name(root)}")
|
|
616
|
+
path.unlink()
|
|
617
|
+
if self.platform == "linux":
|
|
618
|
+
self._systemctl("daemon-reload")
|
|
619
|
+
self._systemctl("reset-failed", self.service_name(root), check=False)
|
|
620
|
+
if self.inspect(root) is not None:
|
|
621
|
+
raise ServiceError("service removal not confirmed")
|
|
622
|
+
|
|
623
|
+
def logs(self, root: Path, *, lines: int = 100) -> str:
|
|
624
|
+
root = self._root(root)
|
|
625
|
+
if lines < 1:
|
|
626
|
+
raise ServiceError("log line count must be positive")
|
|
627
|
+
try:
|
|
628
|
+
require_instance_root_marker(root, uid=self.uid)
|
|
629
|
+
except (OSError, ValueError) as exc:
|
|
630
|
+
raise ServiceError(f"cannot identify the requested instance logs: {exc}") from exc
|
|
631
|
+
sections: list[str] = []
|
|
632
|
+
paths = [("Runtime log", root / "state/netizen.log")]
|
|
633
|
+
if self.platform == "darwin":
|
|
634
|
+
paths.append(("LaunchAgent startup stderr", root / "state/launchd.stderr.log"))
|
|
635
|
+
for label, path in paths:
|
|
636
|
+
try:
|
|
637
|
+
content = _tail_log(path, uid=self.uid, lines=lines)
|
|
638
|
+
content = "(not created)" if content is None else content or "(empty)"
|
|
639
|
+
except (OSError, ServiceError) as exc:
|
|
640
|
+
content = f"(unavailable: {exc})"
|
|
641
|
+
sections.append(f"{label} ({path}):\n{content}")
|
|
642
|
+
if self.platform == "linux":
|
|
643
|
+
try:
|
|
644
|
+
result = self._run("journalctl", "--user", "--unit", self.service_name(root),
|
|
645
|
+
"--no-pager", "--lines", str(lines), "--output=cat")
|
|
646
|
+
diagnostics = result.stdout.strip() or "(no entries)"
|
|
647
|
+
except ServiceError as exc:
|
|
648
|
+
diagnostics = f"(unavailable: {exc})"
|
|
649
|
+
sections.append("Systemd startup diagnostics:\n" + diagnostics)
|
|
650
|
+
# Text from each sink is only displayed, never parsed as service state.
|
|
651
|
+
return "\n\n".join(sections)
|