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
netizen_cli/cli_data.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Instance-owned data and startup migration; never a package installer.
|
|
2
|
+
|
|
3
|
+
Only explicit setup initializes a database. Every actual service start validates
|
|
4
|
+
and, when necessary, migrates it while retaining the same lifetime lock until
|
|
5
|
+
exit. Migration backups are recovery material, not automatic rollback commands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import closing, contextmanager
|
|
12
|
+
import fcntl
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import pwd
|
|
16
|
+
import sqlite3
|
|
17
|
+
import stat
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from .bindings import BindingStore
|
|
25
|
+
from .database_migrations import (
|
|
26
|
+
MigrationPlan,
|
|
27
|
+
migrate_channel_database,
|
|
28
|
+
plan_channel_database,
|
|
29
|
+
)
|
|
30
|
+
from .deployment.update_protocol import install_lock
|
|
31
|
+
from .instance import (
|
|
32
|
+
INSTANCE_ROOT_MARKER,
|
|
33
|
+
INSTANCE_ROOT_MARKER_CONTENT,
|
|
34
|
+
require_instance_root_marker,
|
|
35
|
+
resolve_instance_root,
|
|
36
|
+
)
|
|
37
|
+
from .lark_app import load_lark_app
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
INSTANCE_DATA_MARKER = ".netizen-initialized"
|
|
41
|
+
_DATA_STATES = {
|
|
42
|
+
state: f"netizen-instance-data-v1:{state}\n".encode()
|
|
43
|
+
for state in ("initializing", "initialized", "purging", "purged")
|
|
44
|
+
}
|
|
45
|
+
_BACKUP_MARKER = ".netizen-migration-backup"
|
|
46
|
+
_BACKUP_MARKER_CONTENT = b"netizen-channel-migration-backup-v1\n"
|
|
47
|
+
_DIRECTORIES = ("state", "credentials", "lark-app")
|
|
48
|
+
_PURGE_FILES = (
|
|
49
|
+
"config.yaml", "lark-app/config.json", "credentials/admin-web-secret",
|
|
50
|
+
"state/channel.sqlite3", "state/channel.sqlite3-wal", "state/channel.sqlite3-shm",
|
|
51
|
+
"state/channel.sqlite3-journal", "state/netizen.log", "state/netizen.log.1",
|
|
52
|
+
"state/netizen.log.2", "state/launchd.stderr.log", "state/service.ready",
|
|
53
|
+
"state/service.identity.json",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InstanceDataError(RuntimeError):
|
|
58
|
+
"""The instance cannot be safely initialized, started, or purged."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StartupRejected(InstanceDataError):
|
|
62
|
+
"""Deterministic startup rejection that requires configuration/data repair.
|
|
63
|
+
|
|
64
|
+
Managed service entry points exit cleanly without readiness for this error,
|
|
65
|
+
so the manager does not repeatedly retry an unchanged invalid instance.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _directory(path: Path) -> None:
|
|
70
|
+
info = path.lstat()
|
|
71
|
+
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
|
|
72
|
+
or stat.S_IMODE(info.st_mode) & 0o022):
|
|
73
|
+
raise InstanceDataError(f"unsafe instance directory: {path}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _file(path: Path, *, private: bool = True) -> None:
|
|
77
|
+
info = path.lstat()
|
|
78
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
|
|
79
|
+
or info.st_nlink != 1
|
|
80
|
+
or (private and stat.S_IMODE(info.st_mode) != 0o600)):
|
|
81
|
+
raise InstanceDataError(f"unsafe instance file: {path}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _root(root: Path) -> Path:
|
|
85
|
+
canonical = resolve_instance_root(root)
|
|
86
|
+
if Path(root) != canonical:
|
|
87
|
+
raise InstanceDataError(f"instance operation requires canonical root: {canonical}")
|
|
88
|
+
_directory(canonical)
|
|
89
|
+
try:
|
|
90
|
+
require_instance_root_marker(canonical)
|
|
91
|
+
except (OSError, ValueError) as error:
|
|
92
|
+
raise InstanceDataError(f"instance ownership is not confirmed: {error}") from error
|
|
93
|
+
return canonical
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sync(directory: Path) -> None:
|
|
97
|
+
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
98
|
+
try:
|
|
99
|
+
os.fsync(descriptor)
|
|
100
|
+
finally:
|
|
101
|
+
os.close(descriptor)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _atomic_write(path: Path, payload: bytes) -> None:
|
|
105
|
+
try:
|
|
106
|
+
_file(path)
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
pass
|
|
109
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
112
|
+
output.write(payload)
|
|
113
|
+
output.flush()
|
|
114
|
+
os.fsync(output.fileno())
|
|
115
|
+
os.replace(temporary, path)
|
|
116
|
+
_sync(path.parent)
|
|
117
|
+
finally:
|
|
118
|
+
Path(temporary).unlink(missing_ok=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ensure_instance_root(root: Path) -> None:
|
|
122
|
+
"""Explicit setup's minimal ownership preparation, before any other writes.
|
|
123
|
+
|
|
124
|
+
A preconfigured private config/profile/secret may be supplied. Unmarked state
|
|
125
|
+
and unknown files in managed directories are never silently claimed.
|
|
126
|
+
"""
|
|
127
|
+
canonical = resolve_instance_root(root)
|
|
128
|
+
if Path(root) != canonical:
|
|
129
|
+
raise InstanceDataError(f"instance operation requires canonical root: {canonical}")
|
|
130
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
131
|
+
_directory(root)
|
|
132
|
+
marker = root / INSTANCE_ROOT_MARKER
|
|
133
|
+
if marker.exists() or marker.is_symlink():
|
|
134
|
+
_root(root)
|
|
135
|
+
else:
|
|
136
|
+
for name in _DIRECTORIES:
|
|
137
|
+
directory = root / name
|
|
138
|
+
if directory.exists() or directory.is_symlink():
|
|
139
|
+
_directory(directory)
|
|
140
|
+
allowed = {"credentials": {"admin-web-secret"}, "lark-app": {"config.json"}}.get(name, set())
|
|
141
|
+
for child in directory.iterdir():
|
|
142
|
+
if child.name not in allowed:
|
|
143
|
+
raise InstanceDataError(f"unowned instance data; refusing to claim {child}")
|
|
144
|
+
_file(child)
|
|
145
|
+
for name in ("config.yaml", INSTANCE_DATA_MARKER):
|
|
146
|
+
path = root / name
|
|
147
|
+
if path.exists() or path.is_symlink():
|
|
148
|
+
if name == INSTANCE_DATA_MARKER:
|
|
149
|
+
raise InstanceDataError(f"data marker exists without root ownership: {path}")
|
|
150
|
+
_file(path)
|
|
151
|
+
profile = root / "lark-app" / "config.json"
|
|
152
|
+
if profile.exists():
|
|
153
|
+
load_lark_app(profile, allow_incomplete=True)
|
|
154
|
+
secret = root / "credentials" / "admin-web-secret"
|
|
155
|
+
if secret.exists():
|
|
156
|
+
from .admin.auth import load_credential_snapshot
|
|
157
|
+
load_credential_snapshot(secret)
|
|
158
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".netizen-root-", dir=root)
|
|
159
|
+
try:
|
|
160
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
161
|
+
output.write(INSTANCE_ROOT_MARKER_CONTENT)
|
|
162
|
+
output.flush()
|
|
163
|
+
os.fsync(output.fileno())
|
|
164
|
+
# Publish complete ownership evidence without replacing a concurrent
|
|
165
|
+
# claim; an in-progress marker must never look like a damaged root.
|
|
166
|
+
try:
|
|
167
|
+
os.link(temporary, marker)
|
|
168
|
+
except FileExistsError:
|
|
169
|
+
pass
|
|
170
|
+
_root(root)
|
|
171
|
+
finally:
|
|
172
|
+
Path(temporary).unlink(missing_ok=True)
|
|
173
|
+
_sync(root)
|
|
174
|
+
for name in _DIRECTORIES:
|
|
175
|
+
directory = root / name
|
|
176
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
177
|
+
_directory(directory)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def acquire_lifetime_lock(root: Path) -> int:
|
|
181
|
+
"""Acquire the exclusive service lock without creating an instance."""
|
|
182
|
+
_root(root)
|
|
183
|
+
directory = root / "state"
|
|
184
|
+
_directory(directory)
|
|
185
|
+
descriptor = os.open(directory / "service.lifetime.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
186
|
+
try:
|
|
187
|
+
_file(directory / "service.lifetime.lock")
|
|
188
|
+
os.set_inheritable(descriptor, False)
|
|
189
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
190
|
+
return descriptor
|
|
191
|
+
except BaseException:
|
|
192
|
+
os.close(descriptor)
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@contextmanager
|
|
197
|
+
def instance_lifetime_lock(root: Path) -> Iterator[int]:
|
|
198
|
+
descriptor = acquire_lifetime_lock(root)
|
|
199
|
+
try:
|
|
200
|
+
yield descriptor
|
|
201
|
+
finally:
|
|
202
|
+
os.close(descriptor)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@contextmanager
|
|
206
|
+
def root_maintenance_lock(root: Path) -> Iterator[int]:
|
|
207
|
+
"""Serialize registration/removal/setup; do not hold the service's own lock."""
|
|
208
|
+
_root(root)
|
|
209
|
+
with install_lock(root) as descriptor:
|
|
210
|
+
yield descriptor
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def validate_lifetime_lock(root: Path, descriptor: int) -> None:
|
|
214
|
+
"""Require the exact already-held open-file description, not just a busy file."""
|
|
215
|
+
_root(root)
|
|
216
|
+
path = root / "state" / "service.lifetime.lock"
|
|
217
|
+
_directory(path.parent)
|
|
218
|
+
_file(path)
|
|
219
|
+
actual, expected = os.fstat(descriptor), path.lstat()
|
|
220
|
+
if (not stat.S_ISREG(actual.st_mode)
|
|
221
|
+
or (actual.st_dev, actual.st_ino) != (expected.st_dev, expected.st_ino)):
|
|
222
|
+
raise InstanceDataError("lifetime lock descriptor does not match this instance")
|
|
223
|
+
os.set_inheritable(descriptor, False)
|
|
224
|
+
probe = os.open(path, os.O_RDWR | os.O_NOFOLLOW)
|
|
225
|
+
try:
|
|
226
|
+
try:
|
|
227
|
+
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
228
|
+
except BlockingIOError:
|
|
229
|
+
pass
|
|
230
|
+
else:
|
|
231
|
+
raise InstanceDataError("instance lifetime lock is not held")
|
|
232
|
+
finally:
|
|
233
|
+
os.close(probe)
|
|
234
|
+
try:
|
|
235
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
236
|
+
except BlockingIOError as error:
|
|
237
|
+
raise InstanceDataError("lifetime lock belongs to another process") from error
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _data_state(root: Path) -> str | None:
|
|
241
|
+
marker = root / INSTANCE_DATA_MARKER
|
|
242
|
+
try:
|
|
243
|
+
_file(marker)
|
|
244
|
+
except FileNotFoundError:
|
|
245
|
+
return None
|
|
246
|
+
payload = marker.read_bytes()
|
|
247
|
+
for state, expected in _DATA_STATES.items():
|
|
248
|
+
if payload == expected:
|
|
249
|
+
return state
|
|
250
|
+
raise InstanceDataError(f"unrecognized instance data marker: {marker}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _database(root: Path) -> Path:
|
|
254
|
+
_directory(root / "state")
|
|
255
|
+
path = root / "state" / "channel.sqlite3"
|
|
256
|
+
try:
|
|
257
|
+
_file(path)
|
|
258
|
+
except FileNotFoundError as error:
|
|
259
|
+
raise InstanceDataError("prepared instance database is missing; restore its data, do not recreate an empty database") from error
|
|
260
|
+
for suffix in ("-wal", "-shm", "-journal"):
|
|
261
|
+
try:
|
|
262
|
+
_file(Path(str(path) + suffix))
|
|
263
|
+
except FileNotFoundError:
|
|
264
|
+
pass
|
|
265
|
+
return path
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _check_recovery(root: Path) -> None:
|
|
269
|
+
intent = root / "state" / ".activation-intent.json"
|
|
270
|
+
if intent.exists() or intent.is_symlink():
|
|
271
|
+
raise InstanceDataError("unfinished legacy activation recovery; resolve it manually before CLI startup")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def begin_instance_setup(root: Path, *, lifetime_descriptor: int) -> MigrationPlan | None:
|
|
275
|
+
"""Record explicit setup before configuration or authorization can write data.
|
|
276
|
+
|
|
277
|
+
Return a validated existing database plan, or None for a proven new setup.
|
|
278
|
+
This never creates a database or reclassifies a lost one as a new instance.
|
|
279
|
+
"""
|
|
280
|
+
validate_lifetime_lock(root, lifetime_descriptor)
|
|
281
|
+
_check_recovery(root)
|
|
282
|
+
state = _data_state(root)
|
|
283
|
+
database = root / "state" / "channel.sqlite3"
|
|
284
|
+
has_data = any(
|
|
285
|
+
candidate.exists() or candidate.is_symlink()
|
|
286
|
+
for suffix in ("", "-wal", "-shm", "-journal")
|
|
287
|
+
for candidate in (Path(str(database) + suffix),)
|
|
288
|
+
)
|
|
289
|
+
if state == "initialized" or (state == "initializing" and has_data):
|
|
290
|
+
return plan_channel_database(_database(root))
|
|
291
|
+
if state == "purging":
|
|
292
|
+
raise InstanceDataError("instance purge is incomplete; retry remove --purge before setup")
|
|
293
|
+
for suffix in ("", "-wal", "-shm", "-journal"):
|
|
294
|
+
candidate = Path(str(database) + suffix)
|
|
295
|
+
if candidate.exists() or candidate.is_symlink():
|
|
296
|
+
raise InstanceDataError(f"uninitialized instance has existing database data: {candidate}; explicit manual repair is required")
|
|
297
|
+
if state != "initializing":
|
|
298
|
+
_atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["initializing"])
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def initialize_instance_data(root: Path, *, lifetime_descriptor: int) -> MigrationPlan:
|
|
303
|
+
"""Explicit setup only; a missing DB in an existing instance is never new."""
|
|
304
|
+
plan = begin_instance_setup(root, lifetime_descriptor=lifetime_descriptor)
|
|
305
|
+
if plan is not None:
|
|
306
|
+
if _data_state(root) == "initializing":
|
|
307
|
+
_atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["initialized"])
|
|
308
|
+
return plan
|
|
309
|
+
database = root / "state" / "channel.sqlite3"
|
|
310
|
+
# The durable initializing state proves this is unfinished explicit setup,
|
|
311
|
+
# unlike an initialized instance that lost its DB. An interruption before
|
|
312
|
+
# file creation may retry; any partial existing DB still needs validation.
|
|
313
|
+
descriptor = os.open(database, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
314
|
+
os.close(descriptor)
|
|
315
|
+
try:
|
|
316
|
+
store = BindingStore(database)
|
|
317
|
+
store.close()
|
|
318
|
+
except sqlite3.Error as error:
|
|
319
|
+
raise InstanceDataError(
|
|
320
|
+
"database initialization failed; partial data was preserved; inspect and repair the instance before retrying setup"
|
|
321
|
+
) from error
|
|
322
|
+
plan = plan_channel_database(database)
|
|
323
|
+
_atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["initialized"])
|
|
324
|
+
return plan
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def validate_prepared_instance(root: Path) -> MigrationPlan:
|
|
328
|
+
"""Read-only validation; old supported schemas remain eligible for startup."""
|
|
329
|
+
_root(root)
|
|
330
|
+
_check_recovery(root)
|
|
331
|
+
if _data_state(root) != "initialized":
|
|
332
|
+
raise InstanceDataError("instance is not initialized; run netizen setup explicitly (or repair interrupted setup/purge)")
|
|
333
|
+
_file(root / "config.yaml")
|
|
334
|
+
return plan_channel_database(_database(root))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _backup(root: Path, database: Path) -> Path:
|
|
338
|
+
directory = root / "state" / "migration-backups"
|
|
339
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
340
|
+
_directory(directory)
|
|
341
|
+
backup_dir = directory / uuid.uuid4().hex
|
|
342
|
+
backup_dir.mkdir(mode=0o700)
|
|
343
|
+
destination = backup_dir / "channel.sqlite3"
|
|
344
|
+
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
345
|
+
os.close(descriptor)
|
|
346
|
+
source = sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)
|
|
347
|
+
deadline = time.monotonic() + 30
|
|
348
|
+
def progress(_status: int, _remaining: int, _total: int) -> None:
|
|
349
|
+
if time.monotonic() >= deadline:
|
|
350
|
+
raise TimeoutError("database backup timed out; check for an external database writer")
|
|
351
|
+
try:
|
|
352
|
+
target = sqlite3.connect(destination)
|
|
353
|
+
try:
|
|
354
|
+
source.backup(target, pages=256, progress=progress)
|
|
355
|
+
finally:
|
|
356
|
+
target.close()
|
|
357
|
+
finally:
|
|
358
|
+
source.close()
|
|
359
|
+
descriptor = os.open(destination, os.O_RDONLY | os.O_NOFOLLOW)
|
|
360
|
+
try:
|
|
361
|
+
os.fsync(descriptor)
|
|
362
|
+
finally:
|
|
363
|
+
os.close(descriptor)
|
|
364
|
+
_atomic_write(backup_dir / _BACKUP_MARKER, _BACKUP_MARKER_CONTENT)
|
|
365
|
+
_sync(directory)
|
|
366
|
+
return destination
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def prepare_instance(root: Path, *, lifetime_descriptor: int) -> MigrationPlan:
|
|
370
|
+
"""Actual-start admission, with caller retaining its lock until service exit."""
|
|
371
|
+
try:
|
|
372
|
+
return _prepare_instance(root, lifetime_descriptor=lifetime_descriptor)
|
|
373
|
+
except (RuntimeError, ValueError, FileNotFoundError, PermissionError) as error:
|
|
374
|
+
# SQLite's public error codes distinguish a persistent invalid schema
|
|
375
|
+
# from transient contention/storage failures; never parse its messages.
|
|
376
|
+
cause: BaseException | None = error
|
|
377
|
+
while cause is not None:
|
|
378
|
+
if isinstance(cause, sqlite3.Error) and (
|
|
379
|
+
getattr(cause, "sqlite_errorcode", 0) & 0xff
|
|
380
|
+
) in {
|
|
381
|
+
sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED, sqlite3.SQLITE_IOERR,
|
|
382
|
+
sqlite3.SQLITE_FULL, sqlite3.SQLITE_NOMEM, sqlite3.SQLITE_INTERRUPT,
|
|
383
|
+
sqlite3.SQLITE_CANTOPEN, sqlite3.SQLITE_READONLY,
|
|
384
|
+
}:
|
|
385
|
+
raise
|
|
386
|
+
cause = cause.__cause__
|
|
387
|
+
raise StartupRejected(str(error)) from error
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _prepare_instance(root: Path, *, lifetime_descriptor: int) -> MigrationPlan:
|
|
391
|
+
validate_lifetime_lock(root, lifetime_descriptor)
|
|
392
|
+
plan = validate_prepared_instance(root)
|
|
393
|
+
if plan["steps"]:
|
|
394
|
+
database = _database(root)
|
|
395
|
+
_backup(root, database)
|
|
396
|
+
migrate_channel_database(database, expected_source_version=plan["source_version"])
|
|
397
|
+
else:
|
|
398
|
+
return plan
|
|
399
|
+
# Validate the committed target. Failure never restores an older snapshot.
|
|
400
|
+
return plan_channel_database(_database(root))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _protected_paths(root: Path, *, protected_codex_home: Path | None = None) -> tuple[Path, ...]:
|
|
404
|
+
home = Path(pwd.getpwuid(os.geteuid()).pw_dir)
|
|
405
|
+
paths = [Path(os.environ.get("CODEX_HOME", str(home / ".codex"))).expanduser().resolve()]
|
|
406
|
+
if protected_codex_home is not None:
|
|
407
|
+
if not protected_codex_home.is_absolute():
|
|
408
|
+
raise InstanceDataError("bound service CODEX_HOME must be an absolute path")
|
|
409
|
+
paths.append(protected_codex_home.resolve())
|
|
410
|
+
config = root / "config.yaml"
|
|
411
|
+
if config.exists() or config.is_symlink():
|
|
412
|
+
_file(config)
|
|
413
|
+
try:
|
|
414
|
+
value = yaml.safe_load(config.read_text(encoding="utf-8"))
|
|
415
|
+
if not isinstance(value, dict):
|
|
416
|
+
raise ValueError("configuration root must be a mapping")
|
|
417
|
+
instance = value.get("instance", {})
|
|
418
|
+
projects = value.get("projects", {})
|
|
419
|
+
if not isinstance(instance, dict) or not isinstance(projects, dict):
|
|
420
|
+
raise ValueError("invalid Project configuration")
|
|
421
|
+
for raw in [instance.get("projectRoot"), *projects.values()]:
|
|
422
|
+
if raw is None:
|
|
423
|
+
continue
|
|
424
|
+
if not isinstance(raw, str) or not Path(raw).expanduser().is_absolute():
|
|
425
|
+
raise ValueError("Project paths must be absolute")
|
|
426
|
+
paths.append(Path(raw).expanduser().resolve())
|
|
427
|
+
except (ValueError, OSError, yaml.YAMLError) as error:
|
|
428
|
+
raise InstanceDataError(f"cannot verify protected Project paths before purge: {error}") from error
|
|
429
|
+
elif _data_state(root) not in {"initializing", "purged"}:
|
|
430
|
+
raise InstanceDataError(
|
|
431
|
+
"cannot verify Project paths without instance configuration; preserve remaining files. "
|
|
432
|
+
"Review the previous deletion report and backups for manual recovery or restore the missing configuration. "
|
|
433
|
+
"Do not delete the whole root or alter ownership markers to bypass validation."
|
|
434
|
+
)
|
|
435
|
+
database = root / "state" / "channel.sqlite3"
|
|
436
|
+
if database.exists() or database.is_symlink():
|
|
437
|
+
try:
|
|
438
|
+
# Admin-registered Projects need not appear among YAML seed aliases.
|
|
439
|
+
# Read the supported persisted format without creating a Store,
|
|
440
|
+
# migrating it, or opening a writer in the controlling environment.
|
|
441
|
+
plan_channel_database(_database(root))
|
|
442
|
+
with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=0.25)) as connection:
|
|
443
|
+
connection.execute("PRAGMA query_only=ON")
|
|
444
|
+
for (raw,) in connection.execute("SELECT cwd FROM projects"):
|
|
445
|
+
if not isinstance(raw, str) or not Path(raw).is_absolute():
|
|
446
|
+
raise ValueError("persisted Project path is not absolute")
|
|
447
|
+
paths.append(Path(raw).resolve())
|
|
448
|
+
except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
|
|
449
|
+
raise InstanceDataError("cannot verify persisted Project paths before purge; preserve data and inspect or repair the database") from error
|
|
450
|
+
elif _data_state(root) not in {"initializing", "purged"}:
|
|
451
|
+
# Once deletion has removed that evidence, a partial retry cannot infer
|
|
452
|
+
# the original Project inventory. Fail closed instead of claiming that
|
|
453
|
+
# missing data means the instance never had a registered Project.
|
|
454
|
+
raise InstanceDataError(
|
|
455
|
+
"cannot verify persisted Project paths without the database; preserve remaining files. "
|
|
456
|
+
"Review the previous deletion report and backups for manual recovery or restore the missing database. "
|
|
457
|
+
"Do not delete the whole root or alter ownership markers to bypass validation."
|
|
458
|
+
)
|
|
459
|
+
return tuple(paths)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def purge_inventory(root: Path, *, protected_codex_home: Path | None = None) -> tuple[Path, ...]:
|
|
463
|
+
"""An explicit finite file inventory, never recursive ownership by location."""
|
|
464
|
+
_root(root)
|
|
465
|
+
if _data_state(root) not in {"initialized", "initializing", "purging", "purged"}:
|
|
466
|
+
raise InstanceDataError("instance data ownership is not established; refusing purge")
|
|
467
|
+
for name in _DIRECTORIES:
|
|
468
|
+
directory = root / name
|
|
469
|
+
if directory.exists() or directory.is_symlink():
|
|
470
|
+
_directory(directory)
|
|
471
|
+
protected = _protected_paths(root, protected_codex_home=protected_codex_home)
|
|
472
|
+
paths = []
|
|
473
|
+
for name in _PURGE_FILES:
|
|
474
|
+
candidate = root / name
|
|
475
|
+
try:
|
|
476
|
+
_file(candidate)
|
|
477
|
+
except FileNotFoundError:
|
|
478
|
+
continue
|
|
479
|
+
paths.append(candidate)
|
|
480
|
+
backups = root / "state" / "migration-backups"
|
|
481
|
+
if backups.exists() or backups.is_symlink():
|
|
482
|
+
_directory(backups)
|
|
483
|
+
for child in sorted(backups.iterdir()):
|
|
484
|
+
# Other files/directories remain user-owned, even in this namespace.
|
|
485
|
+
if len(child.name) != 32 or any(c not in "0123456789abcdef" for c in child.name):
|
|
486
|
+
continue
|
|
487
|
+
_directory(child)
|
|
488
|
+
marker = child / _BACKUP_MARKER
|
|
489
|
+
if not marker.exists() and not marker.is_symlink():
|
|
490
|
+
continue
|
|
491
|
+
_file(marker)
|
|
492
|
+
if marker.read_bytes() != _BACKUP_MARKER_CONTENT:
|
|
493
|
+
raise InstanceDataError(f"unrecognized migration backup marker: {marker}")
|
|
494
|
+
database = child / "channel.sqlite3"
|
|
495
|
+
if database.exists() or database.is_symlink():
|
|
496
|
+
_file(database)
|
|
497
|
+
paths.append(database)
|
|
498
|
+
paths.append(marker)
|
|
499
|
+
for candidate in paths:
|
|
500
|
+
if any(candidate == path or candidate.is_relative_to(path) or path.is_relative_to(candidate) for path in protected):
|
|
501
|
+
raise InstanceDataError(f"purge target overlaps Project or shared Codex state: {candidate}")
|
|
502
|
+
# Delete configuration last so a partial purge retry can still check Projects.
|
|
503
|
+
return tuple(sorted(paths, key=lambda path: (
|
|
504
|
+
path == root / "config.yaml", path.name == _BACKUP_MARKER, str(path),
|
|
505
|
+
)))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class InstancePurgeError(InstanceDataError):
|
|
509
|
+
def __init__(self, message: str, deleted: tuple[Path, ...]) -> None:
|
|
510
|
+
super().__init__(message)
|
|
511
|
+
self.deleted = deleted
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def purge_instance_data(
|
|
515
|
+
root: Path, *, expected_inventory: tuple[Path, ...], lifetime_descriptor: int,
|
|
516
|
+
protected_codex_home: Path | None = None,
|
|
517
|
+
) -> tuple[Path, ...]:
|
|
518
|
+
"""Caller has confirmed service removal; preserve root, locks and unknowns."""
|
|
519
|
+
validate_lifetime_lock(root, lifetime_descriptor)
|
|
520
|
+
inventory = purge_inventory(root, protected_codex_home=protected_codex_home)
|
|
521
|
+
# Clean shutdown may remove WAL/SHM or readiness files shown at confirmation.
|
|
522
|
+
# A narrower inventory is safe; newly discovered targets need confirmation.
|
|
523
|
+
if not set(inventory).issubset(expected_inventory):
|
|
524
|
+
raise InstanceDataError("purge inventory changed since confirmation; inspect and confirm again")
|
|
525
|
+
_atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["purging"])
|
|
526
|
+
deleted: list[Path] = []
|
|
527
|
+
try:
|
|
528
|
+
for path in inventory:
|
|
529
|
+
_file(path)
|
|
530
|
+
path.unlink()
|
|
531
|
+
deleted.append(path)
|
|
532
|
+
_sync(path.parent)
|
|
533
|
+
_atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["purged"])
|
|
534
|
+
except (OSError, InstanceDataError) as error:
|
|
535
|
+
raise InstancePurgeError(f"instance purge stopped after {len(deleted)} files: {error}", tuple(deleted)) from error
|
|
536
|
+
return tuple(deleted)
|