toolframe-core 0.6.2__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 (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
@@ -0,0 +1,868 @@
1
+ """Operator-only, read-only inventory of a stopped local application.
2
+
3
+ Keep :func:`stopped_application` open for the entire snapshot operation. The
4
+ serialized inventory describes files without configuration or credential values.
5
+ Protected environment locks can contain credentials and must remain private.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import platform
14
+ import re
15
+ import sqlite3
16
+ import stat
17
+ import subprocess
18
+ import tomllib
19
+ from contextlib import contextmanager
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path, PurePosixPath
22
+ from urllib.parse import unquote, urlsplit
23
+
24
+ from packaging.markers import Marker
25
+ from packaging.specifiers import SpecifierSet
26
+ from packaging.tags import sys_tags
27
+ from packaging.utils import canonicalize_name, parse_wheel_filename
28
+ from packaging.version import Version
29
+ from toolframe_sdk import PluginError
30
+
31
+ from .config import Composition, ConfigFile, HostLock, validate_plugin_id
32
+ from .state import StateSettings
33
+
34
+ __all__ = [
35
+ "DependencyWheel",
36
+ "EnvironmentLock",
37
+ "InventoryFile",
38
+ "RetainedKey",
39
+ "SQLiteStore",
40
+ "StoppedApplicationInventory",
41
+ "application_lock",
42
+ "stopped_application",
43
+ ]
44
+
45
+ _HASH = re.compile(r"[a-f0-9]{64}")
46
+ _RUN = re.compile(r"[a-f0-9]{32}")
47
+ _DISTRIBUTION = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?")
48
+ _LOCAL_STORAGE = "storage-sqlite"
49
+ _LOCAL_STORAGE_FACTORY = "toolframe_storage_sqlite.plugin:SqliteProvider"
50
+ _LOCAL_PROVIDERS = {
51
+ "dashboard",
52
+ "mcp",
53
+ "llms",
54
+ "auth",
55
+ "dashboard-auth",
56
+ "mcp-auth",
57
+ "oidc-auth",
58
+ }
59
+ _OMIT = {
60
+ ".toolframe/host.lock",
61
+ ".toolframe/endpoint.json",
62
+ ".toolframe/environments",
63
+ ".toolframe/tmp",
64
+ }
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class InventoryFile:
69
+ path: str
70
+ role: str
71
+ size: int
72
+ mode: int
73
+ sha256: str | None = None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SQLiteStore:
78
+ path: str
79
+ owner: str | None
80
+ provider: str
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class RetainedKey:
85
+ path: str
86
+ owner: str | None
87
+ name: str
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class DependencyWheel:
92
+ environment_id: str
93
+ distribution: str
94
+ version: str
95
+ filename: str
96
+ sha256: str
97
+ path: str | None
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class EnvironmentLock:
102
+ candidate_id: str
103
+ purpose: str
104
+ environment_id: str
105
+ sha256: str
106
+ python_version: str
107
+ python_implementation: str
108
+ platform_system: str
109
+ platform_machine: str
110
+ requires_python: str
111
+ lock: str = field(repr=False)
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class StoppedApplicationInventory:
116
+ home: Path
117
+ application_id: str
118
+ application_name: str
119
+ composition_revision: str
120
+ files: tuple[InventoryFile, ...]
121
+ stores: tuple[SQLiteStore, ...]
122
+ retained_keys: tuple[RetainedKey, ...]
123
+ dependency_wheels: tuple[DependencyWheel, ...]
124
+ environment_locks: tuple[EnvironmentLock, ...]
125
+ format_version: int = 1
126
+ status: str = "STOPPED"
127
+
128
+ def as_dict(self) -> dict:
129
+ """Return manifest metadata with home-relative paths and no secret values."""
130
+ result = asdict(self)
131
+ result.pop("home")
132
+ # Arrays, rather than dataclass tuples, are part of the JSON contract.
133
+ for name in (
134
+ "files",
135
+ "stores",
136
+ "retained_keys",
137
+ "dependency_wheels",
138
+ "environment_locks",
139
+ ):
140
+ result[name] = list(result[name])
141
+ for environment in result["environment_locks"]:
142
+ environment.pop("lock")
143
+ return result
144
+
145
+
146
+ def _fail(code="maintenance_layout_invalid", message="Application layout is invalid"):
147
+ raise PluginError(code, message) from None
148
+
149
+
150
+ def _relative(value):
151
+ if (
152
+ not isinstance(value, str)
153
+ or not value
154
+ or "\\" in value
155
+ or PurePosixPath(value).is_absolute()
156
+ or any(part in {"", ".", ".."} for part in value.split("/"))
157
+ ):
158
+ _fail(message="Application paths must be safe home-relative paths")
159
+ return value
160
+
161
+
162
+ def _path(home, value, *, required=True):
163
+ relative = _relative(value)
164
+ current = home
165
+ for part in PurePosixPath(relative).parts:
166
+ current = current / part
167
+ if current.is_symlink():
168
+ _fail(message="Symlinks are not supported in snapshot state")
169
+ if required and not current.is_file():
170
+ _fail(message="A required application file is missing")
171
+ return current
172
+
173
+
174
+ def _json(text):
175
+ return json.loads(text, parse_constant=lambda _: _fail())
176
+
177
+
178
+ @contextmanager
179
+ def _database(home, relative):
180
+ path = _path(home, relative)
181
+ for suffix in ("-wal", "-journal"):
182
+ pending = _path(home, relative + suffix, required=False)
183
+ if pending.exists() and (not pending.is_file() or pending.stat().st_size):
184
+ _fail(
185
+ "maintenance_recovery_required",
186
+ "Cleanly stop SQLite and clear pending journals before maintenance",
187
+ )
188
+ # immutable avoids creating shared-memory/WAL files. Pending journals were
189
+ # explicitly refused above, so no committed pages can be silently omitted.
190
+ connection = sqlite3.connect(path.as_uri() + "?mode=ro&immutable=1", uri=True)
191
+ try:
192
+ connection.execute("PRAGMA query_only=ON")
193
+ if connection.execute("PRAGMA quick_check").fetchall() != [("ok",)]:
194
+ _fail(message="SQLite integrity validation failed")
195
+ yield connection
196
+ finally:
197
+ connection.close()
198
+
199
+
200
+ def _processes():
201
+ try:
202
+ result = subprocess.run(
203
+ ["/bin/ps", "-axo", "pid=,pgid=,command="],
204
+ check=True,
205
+ capture_output=True,
206
+ text=True,
207
+ timeout=10,
208
+ )
209
+ rows = []
210
+ seen = set()
211
+ if result.returncode:
212
+ _fail("maintenance_ownership_unproven", "Process inventory failed")
213
+ for line in result.stdout.splitlines():
214
+ parts = line.split(None, 2)
215
+ if len(parts) != 3 or not all(part.isdigit() for part in parts[:2]):
216
+ _fail(
217
+ "maintenance_ownership_unproven", "Process inventory is incomplete"
218
+ )
219
+ pid, pgid = map(int, parts[:2])
220
+ if pid < 1 or pgid < 1 or pid in seen:
221
+ _fail("maintenance_ownership_unproven", "Process inventory is invalid")
222
+ seen.add(pid)
223
+ rows.append(parts[2])
224
+ if not rows:
225
+ _fail("maintenance_ownership_unproven", "Process inventory is unavailable")
226
+ return rows
227
+ except OSError, subprocess.SubprocessError, UnicodeError:
228
+ _fail(
229
+ "maintenance_ownership_unproven", "Cannot verify worker process ownership"
230
+ )
231
+
232
+
233
+ def _ownership(metadata, plugins, composition):
234
+ run = metadata.get("host_run")
235
+ owners = metadata.get("worker_owners", {})
236
+ if (
237
+ not isinstance(run, str)
238
+ or not _RUN.fullmatch(run)
239
+ or not isinstance(owners, dict)
240
+ ):
241
+ _fail(
242
+ "maintenance_ownership_unproven",
243
+ "Initialized worker ownership is unavailable",
244
+ )
245
+ # A stale entry is an unproven cleanup, even when its PID is absent. Recovery
246
+ # belongs to Core's lifecycle, not a snapshot reader.
247
+ if owners:
248
+ _fail(
249
+ "maintenance_ownership_unproven",
250
+ "Worker cleanup must be reconciled before maintenance",
251
+ )
252
+ runs = {run}
253
+
254
+ def observation(value, plugin):
255
+ if not isinstance(value, dict):
256
+ _fail("maintenance_ownership_unproven", "Worker observation is invalid")
257
+ if value == {}:
258
+ request = composition.plugins.get(plugin["id"])
259
+ if (
260
+ plugin["current_candidate"]
261
+ or plugin["previous_candidate"]
262
+ or (request is not None and request.enabled)
263
+ ):
264
+ _fail(
265
+ "maintenance_ownership_unproven",
266
+ "A selected worker has no durable stop observation",
267
+ )
268
+ return
269
+ recorded = value.get("host_run")
270
+ if not isinstance(recorded, str) or not _RUN.fullmatch(recorded):
271
+ _fail("maintenance_ownership_unproven", "Worker observation is invalid")
272
+ runs.add(recorded)
273
+ if value.get("state") != "stopped":
274
+ _fail(
275
+ "maintenance_ownership_unproven",
276
+ "Worker stop was not durably confirmed",
277
+ )
278
+
279
+ for plugin in plugins:
280
+ observation(_json(plugin["runtime"]), plugin)
281
+ for command in _processes():
282
+ if "toolframe_sdk.worker" in command and any(
283
+ re.search(r"(?:^|\s)--host-token\s+" + run + r"/", command) for run in runs
284
+ ):
285
+ _fail(
286
+ "maintenance_ownership_unproven",
287
+ "An application worker is still present",
288
+ )
289
+
290
+
291
+ def _artifact(home, relative, expected, artifacts):
292
+ if not isinstance(expected, str) or not _HASH.fullmatch(expected):
293
+ _fail(message="Immutable artifact checksum is missing")
294
+ relative = _relative(relative)
295
+ if not relative.startswith(f".toolframe/artifacts/{expected}/"):
296
+ _fail(message="Immutable artifacts must use the captured home layout")
297
+ path = _path(home, relative, required=False)
298
+ if not path.is_file():
299
+ _fail(
300
+ "maintenance_artifact_missing",
301
+ "Capture every required dependency wheel before maintenance",
302
+ )
303
+ with path.open("rb") as source:
304
+ checksum = hashlib.file_digest(source, "sha256").hexdigest()
305
+ if checksum != expected:
306
+ _fail(message="An immutable artifact checksum does not match")
307
+ artifacts[relative] = checksum
308
+
309
+
310
+ def _environment(home, value, artifacts, wheels):
311
+ if not isinstance(value, dict) or not isinstance(value.get("lock"), str):
312
+ _fail(message="A retained environment lock is missing")
313
+ identity = value.get("id")
314
+ if not isinstance(identity, str) or not _HASH.fullmatch(identity):
315
+ _fail(message="A retained environment identity is invalid")
316
+ if value.get("python") != f".toolframe/environments/{identity}/bin/python":
317
+ _fail(message="A retained environment path is invalid")
318
+ lock = tomllib.loads(value["lock"])
319
+ if lock.get("lock-version") != "1.0":
320
+ _fail(message="A retained environment lock format is unsupported")
321
+ compatibility = _compatibility(home, value, lock)
322
+ supported_tags = set(sys_tags())
323
+ packages = lock.get("packages")
324
+ if not isinstance(packages, list) or not packages:
325
+ _fail(message="A retained environment has no locked wheels")
326
+ for package in packages:
327
+ if not isinstance(package, dict) or any(
328
+ field in package for field in ("directory", "vcs")
329
+ ):
330
+ _fail(
331
+ "maintenance_artifact_missing",
332
+ "Only fully captured wheel environments are supported",
333
+ )
334
+ direct = "archive" in package
335
+ if direct:
336
+ archive = package["archive"]
337
+ if (
338
+ not isinstance(archive, dict)
339
+ or any(field in package for field in ("wheels", "sdist"))
340
+ or "subdirectory" in archive
341
+ or ("path" in archive) == ("url" in archive)
342
+ ):
343
+ _fail(message="A direct dependency wheel record is invalid")
344
+ wheel = dict(archive)
345
+ if "path" in archive:
346
+ if not isinstance(archive["path"], str) or not archive["path"]:
347
+ _fail(message="A direct dependency wheel path is invalid")
348
+ path = Path(archive["path"].replace("${TOOLFRAME_HOME}", str(home)))
349
+ # Restrict direct paths to captured source-home artifacts. The
350
+ # public lock stays exact, so restore consumers relocate paths.
351
+ if not path.is_absolute():
352
+ _fail(message="A direct dependency wheel path must be absolute")
353
+ wheel["url"] = path.as_uri()
354
+ package_wheels = [wheel]
355
+ else:
356
+ package_wheels = package.get("wheels")
357
+ if not isinstance(package_wheels, list) or not package_wheels:
358
+ _fail(
359
+ "maintenance_artifact_missing",
360
+ "Only fully captured wheel environments are supported",
361
+ )
362
+ marker = package.get("marker")
363
+ if marker is not None:
364
+ if not isinstance(marker, str):
365
+ _fail(message="A dependency marker is invalid")
366
+ if not Marker(marker).evaluate():
367
+ continue
368
+ if not isinstance(package.get("name"), str) or not isinstance(
369
+ package.get("version"), str
370
+ ):
371
+ _fail(message="A dependency identity is missing")
372
+ if not _DISTRIBUTION.fullmatch(package["name"]):
373
+ _fail(message="A dependency identity is invalid")
374
+ version = Version(package["version"])
375
+ compatible_wheel = False
376
+ for wheel in package_wheels:
377
+ if (
378
+ not isinstance(wheel, dict)
379
+ or not isinstance(wheel.get("url"), str)
380
+ or not isinstance(wheel.get("hashes"), dict)
381
+ ):
382
+ _fail(message="A dependency wheel record is invalid")
383
+ uri = urlsplit(wheel.get("url", "").replace("${TOOLFRAME_HOME}", str(home)))
384
+ checksum = wheel.get("hashes", {}).get("sha256")
385
+ filename = unquote(PurePosixPath(uri.path).name)
386
+ _relative(filename)
387
+ if "/" in filename:
388
+ _fail(message="A dependency wheel filename must be a basename")
389
+ if direct and not filename.endswith(".whl"):
390
+ _fail(
391
+ "maintenance_artifact_missing",
392
+ "Direct dependencies must be captured wheel archives",
393
+ )
394
+ wheel_name, wheel_version, _, tags = parse_wheel_filename(filename)
395
+ compatible_wheel |= bool(tags & supported_tags)
396
+ if (
397
+ wheel_name != canonicalize_name(package["name"])
398
+ or wheel_version != version
399
+ ):
400
+ _fail(message="A dependency wheel differs from its locked package")
401
+ if (
402
+ not filename.endswith(".whl")
403
+ or not isinstance(checksum, str)
404
+ or not _HASH.fullmatch(checksum)
405
+ ):
406
+ _fail(message="A dependency wheel must have a locked SHA256")
407
+ if uri.scheme == "file" and uri.netloc in ("", "localhost"):
408
+ try:
409
+ relative = Path(unquote(uri.path)).relative_to(home).as_posix()
410
+ except ValueError:
411
+ _fail(message="A dependency wheel escapes the application home")
412
+ elif uri.scheme == "https":
413
+ relative = f".toolframe/artifacts/{checksum}/{filename}"
414
+ if not _path(home, relative, required=False).exists():
415
+ relative = None
416
+ else:
417
+ _fail(
418
+ "maintenance_artifact_missing",
419
+ "A dependency wheel is not captured locally",
420
+ )
421
+ if relative is not None:
422
+ _artifact(home, relative, checksum, artifacts)
423
+ elif direct:
424
+ _fail(
425
+ "maintenance_artifact_missing",
426
+ "A direct dependency wheel is not captured locally",
427
+ )
428
+ item = DependencyWheel(
429
+ identity,
430
+ package["name"],
431
+ package["version"],
432
+ filename,
433
+ checksum,
434
+ relative,
435
+ )
436
+ wheels[(identity, filename, checksum)] = item
437
+ if not compatible_wheel:
438
+ _fail(
439
+ message="Recorded dependency wheels do not support the source inspection platform"
440
+ )
441
+ return compatibility
442
+
443
+
444
+ def _compatibility(home, environment, lock):
445
+ config = _path(home, f".toolframe/environments/{environment['id']}/pyvenv.cfg")
446
+ settings = {}
447
+ for line in config.read_text().splitlines():
448
+ if not line.strip():
449
+ continue
450
+ key, separator, value = line.partition("=")
451
+ key = key.strip()
452
+ if not separator or key in settings:
453
+ _fail(message="Source interpreter metadata is invalid")
454
+ settings[key] = value.strip()
455
+ implementation = settings.get("implementation")
456
+ inspector_version = Version(platform.python_version())
457
+ versions = []
458
+ for name in ("version_info", "version"):
459
+ if name not in settings:
460
+ continue
461
+ if not re.fullmatch(r"[0-9]+\.[0-9]+(?:\.[0-9]+)?", settings[name]):
462
+ _fail(message="Source interpreter version metadata is invalid")
463
+ recorded = Version(settings[name])
464
+ precision = len(recorded.release)
465
+ if (precision == 2 and recorded.release != inspector_version.release[:2]) or (
466
+ precision == 3 and recorded != inspector_version
467
+ ):
468
+ _fail(message="Use a compatible source interpreter for offline maintenance")
469
+ versions.append(recorded)
470
+ if not versions or implementation != platform.python_implementation():
471
+ _fail(message="Use a compatible source interpreter for offline maintenance")
472
+ # uv can record only major/minor. Preserve that uncertainty in the public
473
+ # metadata; use a full recorded version when either field supplies one.
474
+ version = str(max(versions, key=lambda item: len(item.release)))
475
+ requires_python = lock.get("requires-python", "")
476
+ environments = lock.get("environments", [])
477
+ if (
478
+ not isinstance(requires_python, str)
479
+ or not isinstance(environments, list)
480
+ or any(not isinstance(marker, str) for marker in environments)
481
+ ):
482
+ _fail(message="Environment compatibility markers are invalid")
483
+ if not SpecifierSet(requires_python).contains(inspector_version) or (
484
+ environments and not any(Marker(marker).evaluate() for marker in environments)
485
+ ):
486
+ _fail(
487
+ message="The recorded lock does not support the source inspection platform"
488
+ )
489
+ return (
490
+ version,
491
+ implementation,
492
+ platform.system(),
493
+ platform.machine(),
494
+ requires_python,
495
+ )
496
+
497
+
498
+ def _inventory(home):
499
+ try:
500
+ composition, revision = ConfigFile(home).read()
501
+ except PluginError:
502
+ _fail(message="Application composition is invalid")
503
+ lock = _json(_path(home, "toolframe.lock").read_text())
504
+ if (
505
+ not isinstance(lock, dict)
506
+ or lock.get("format") != 1
507
+ or not isinstance(lock.get("plugins"), dict)
508
+ ):
509
+ _fail(message="Application lock format is unsupported")
510
+ with _database(home, "data/core.sqlite") as database:
511
+ database.row_factory = sqlite3.Row
512
+ if database.execute("PRAGMA user_version").fetchone()[0] not in (1, 2):
513
+ _fail(message="Initialized Core journal schema is unsupported")
514
+ metadata = {
515
+ row[0]: _json(row[1])
516
+ for row in database.execute("SELECT key,value FROM metadata")
517
+ }
518
+ plugins = [
519
+ dict(row) for row in database.execute("SELECT * FROM plugins ORDER BY id")
520
+ ]
521
+ _ownership(metadata, plugins, composition)
522
+ if database.execute(
523
+ "SELECT 1 FROM operations WHERE status IN ('queued','running','cancelling') LIMIT 1"
524
+ ).fetchone() or metadata.get("forward_repair"):
525
+ _fail(
526
+ "maintenance_recovery_required",
527
+ "Resolve interrupted management work before maintenance",
528
+ )
529
+ previous = metadata.get("last_valid_composition")
530
+ if (
531
+ not isinstance(previous, dict)
532
+ or previous.get("application", {}).get("id") != composition.application.id
533
+ ):
534
+ _fail(message="Composition and initialized journal identities differ")
535
+ desired_revision = lock.get("desired_revision")
536
+ if (
537
+ not isinstance(desired_revision, str)
538
+ or not _HASH.fullmatch(desired_revision)
539
+ or desired_revision != metadata.get("desired_revision")
540
+ ):
541
+ _fail(message="Composition lock and journal revisions differ")
542
+ if revision != desired_revision:
543
+ _fail(
544
+ "maintenance_recovery_required",
545
+ "Apply the current composition and cleanly stop Core before maintenance",
546
+ )
547
+ recorded = Composition.model_validate(previous)
548
+ effective = composition.model_copy(deep=True)
549
+ wheelhouse = os.environ.get("TOOLFRAME_WHEELHOUSE")
550
+ if wheelhouse and wheelhouse not in effective.packages.find_links:
551
+ effective.packages.find_links.append(wheelhouse)
552
+ if os.environ.get("TOOLFRAME_OFFLINE") == "1":
553
+ effective.packages.offline = True
554
+ if recorded.model_dump() != effective.model_dump():
555
+ _fail(
556
+ message="Recorded composition differs from current runtime configuration"
557
+ )
558
+ if set(lock["plugins"]) != set(composition.plugins):
559
+ _fail(message="Composition plugin coverage differs from its lock")
560
+ candidates = {}
561
+ for plugin in plugins:
562
+ validate_plugin_id(plugin["id"])
563
+ request = composition.plugins.get(plugin["id"])
564
+ if request is not None and (
565
+ not any(
566
+ plugin[selection]
567
+ for selection in (
568
+ "current_candidate",
569
+ "last_candidate",
570
+ "previous_candidate",
571
+ )
572
+ )
573
+ or (request.enabled and not plugin["current_candidate"])
574
+ ):
575
+ _fail(message="A configured plugin has no complete candidate selection")
576
+ for selection in (
577
+ "current_candidate",
578
+ "previous_candidate",
579
+ "last_candidate",
580
+ ):
581
+ identifier = plugin[selection]
582
+ if identifier:
583
+ if not isinstance(identifier, str) or not _HASH.fullmatch(
584
+ identifier
585
+ ):
586
+ _fail(message="A retained candidate identity is invalid")
587
+ row = database.execute(
588
+ "SELECT data FROM candidates WHERE id=? AND plugin_id=?",
589
+ (identifier, plugin["id"]),
590
+ ).fetchone()
591
+ if row is None:
592
+ _fail(message="A retained candidate is missing")
593
+ candidate = _json(row[0])
594
+ if (
595
+ candidate.get("id") != identifier
596
+ or candidate.get("plugin_id") != plugin["id"]
597
+ ):
598
+ _fail(message="A retained candidate identity is invalid")
599
+ candidates[identifier] = candidate
600
+ locked = lock["plugins"].get(plugin["id"], {}).get(selection)
601
+ if plugin["id"] in lock["plugins"] and bool(locked) != bool(identifier):
602
+ _fail(message="A locked candidate differs from the journal")
603
+ if locked and any(
604
+ locked.get(key) != candidate.get(key)
605
+ for key in (
606
+ "id",
607
+ "version",
608
+ "artifact",
609
+ "artifact_hash",
610
+ "source",
611
+ "environment",
612
+ )
613
+ ):
614
+ _fail(message="A locked candidate differs from the journal")
615
+ if (
616
+ locked
617
+ and "ui_environment" in locked
618
+ and locked["ui_environment"] != candidate.get("ui_environment")
619
+ ):
620
+ _fail(message="A locked UI environment differs from the journal")
621
+ if set(composition.plugins) - {plugin["id"] for plugin in plugins}:
622
+ _fail(message="A locked plugin is missing from the journal")
623
+
624
+ artifacts, definitions, wheels, environments = {}, {}, {}, []
625
+ for candidate in candidates.values():
626
+ definition = candidate["definition"]
627
+ if (
628
+ not isinstance(definition, dict)
629
+ or definition.get("id") != candidate["plugin_id"]
630
+ ):
631
+ _fail(message="A retained plugin definition is invalid")
632
+ kind = definition.get("provider_kind")
633
+ if (
634
+ definition.get("runtime_validation") == "separate_application"
635
+ or (
636
+ kind == "storage"
637
+ and (
638
+ definition["id"] != _LOCAL_STORAGE
639
+ or definition.get("factory") != _LOCAL_STORAGE_FACTORY
640
+ )
641
+ )
642
+ or (kind is not None and kind not in _LOCAL_PROVIDERS | {"storage"})
643
+ ):
644
+ _fail(
645
+ "maintenance_external_state",
646
+ "External or unknown provider-managed state cannot be captured by local maintenance",
647
+ )
648
+ definitions.setdefault(definition["id"], []).append(definition)
649
+ _artifact(home, candidate["artifact"], candidate["artifact_hash"], artifacts)
650
+ for purpose, environment_field in (
651
+ ("backend", "environment"),
652
+ ("ui", "ui_environment"),
653
+ ):
654
+ if environment_field == "environment" or candidate.get(environment_field):
655
+ environment = candidate[environment_field]
656
+ compatibility = _environment(home, environment, artifacts, wheels)
657
+ environments.append(
658
+ EnvironmentLock(
659
+ candidate["id"],
660
+ purpose,
661
+ environment["id"],
662
+ hashlib.sha256(environment["lock"].encode()).hexdigest(),
663
+ *compatibility,
664
+ environment["lock"],
665
+ )
666
+ )
667
+
668
+ bindings = metadata.get("store_bindings", {})
669
+ if not isinstance(bindings, dict):
670
+ _fail(message="Retained store bindings are invalid")
671
+ stores = [SQLiteStore("data/core.sqlite", None, "core")]
672
+ keys = []
673
+ for owner, provider in bindings.items():
674
+ validate_plugin_id(owner)
675
+ if (
676
+ owner not in definitions
677
+ or provider != _LOCAL_STORAGE
678
+ or provider not in definitions
679
+ ):
680
+ _fail(
681
+ "maintenance_external_state",
682
+ "A retained store has external or uncertain ownership",
683
+ )
684
+ relative = f"data/plugins/{owner}/data.sqlite"
685
+ with _database(home, relative) as database:
686
+ identity = database.execute(
687
+ "SELECT owner,application_id FROM _toolframe_identity"
688
+ ).fetchall()
689
+ if identity != [(owner, composition.application.id)]:
690
+ _fail(message="A business store identity differs from the application")
691
+ stores.append(SQLiteStore(relative, owner, provider))
692
+ for owner, values in definitions.items():
693
+ for name in {
694
+ name
695
+ for definition in values
696
+ for name in definition.get("private_secrets", [])
697
+ }:
698
+ validate_plugin_id(name)
699
+ relative = f".toolframe/secrets/{owner}/{name}.key"
700
+ path = _path(home, relative, required=False)
701
+ if path.exists():
702
+ if not path.is_file() or path.stat().st_mode & 0o077:
703
+ _fail(
704
+ message="Retained private keys require owner-only regular files"
705
+ )
706
+ keys.append(RetainedKey(relative, owner, name))
707
+ elif owner in bindings:
708
+ _fail(message="A retained business store private key is missing")
709
+
710
+ roles = {"toolframe.toml": "composition", "toolframe.lock": "lock"}
711
+ roles.update(
712
+ {
713
+ item.path: "core_store" if item.owner is None else "business_store"
714
+ for item in stores
715
+ }
716
+ )
717
+ roles.update({path: "artifact" for path in artifacts})
718
+ roles.update({item.path: "retained_key" for item in keys})
719
+ files = []
720
+ omitted = _OMIT | {
721
+ store.path + suffix
722
+ for store in stores
723
+ for suffix in ("-shm", "-wal", "-journal")
724
+ }
725
+
726
+ def walk(directory):
727
+ for path in sorted(directory.iterdir()):
728
+ relative = path.relative_to(home).as_posix()
729
+ mode = path.lstat().st_mode
730
+ if stat.S_ISLNK(mode):
731
+ _fail(message="Symlinks are not supported in snapshot state")
732
+ parts = PurePosixPath(relative).parts
733
+ if (
734
+ len(parts) == 4
735
+ and parts[:2] == ("data", "plugins")
736
+ and parts[3]
737
+ in {
738
+ "data.sqlite",
739
+ "data.sqlite-wal",
740
+ "data.sqlite-journal",
741
+ "data.sqlite-shm",
742
+ }
743
+ and "/".join((*parts[:3], "data.sqlite")) not in roles
744
+ ):
745
+ _fail(
746
+ "maintenance_external_state",
747
+ "A business store has no retained provider binding",
748
+ )
749
+ if relative in omitted or stat.S_ISSOCK(mode):
750
+ continue
751
+ if stat.S_ISDIR(mode):
752
+ walk(path)
753
+ elif stat.S_ISREG(mode):
754
+ role = roles.get(relative, "home_file")
755
+ if relative == ".toolframe/management.token" or relative.startswith(
756
+ ".toolframe/secrets/"
757
+ ):
758
+ role = "retained_key"
759
+ if mode & 0o077:
760
+ _fail(
761
+ message="Retained credentials require owner-only regular files"
762
+ )
763
+ if relative not in {key.path for key in keys}:
764
+ keys.append(
765
+ RetainedKey(
766
+ relative,
767
+ None,
768
+ "management"
769
+ if relative.endswith("management.token")
770
+ else path.stem,
771
+ )
772
+ )
773
+ files.append(
774
+ InventoryFile(
775
+ relative,
776
+ role,
777
+ path.stat().st_size,
778
+ stat.S_IMODE(mode),
779
+ artifacts.get(relative),
780
+ )
781
+ )
782
+ else:
783
+ _fail(message="Only regular in-home snapshot files are supported")
784
+
785
+ walk(home)
786
+ return StoppedApplicationInventory(
787
+ home,
788
+ composition.application.id,
789
+ composition.application.name,
790
+ revision,
791
+ tuple(files),
792
+ tuple(stores),
793
+ tuple(sorted(keys, key=lambda item: item.path)),
794
+ tuple(wheels[key] for key in sorted(wheels)),
795
+ tuple(environments),
796
+ )
797
+
798
+
799
+ def _home(home):
800
+ home = Path(os.path.abspath(Path(home).expanduser()))
801
+ if home.is_symlink() or not home.is_dir():
802
+ _fail(message="An existing regular application home is required")
803
+ return home.resolve()
804
+
805
+
806
+ @contextmanager
807
+ def application_lock(home: str | Path):
808
+ """Exclusively lock an existing home, without asserting worker/state safety.
809
+
810
+ Creates only HostLock's directory/PID file. A restore consumer may keep this
811
+ context open while moving its own new staging home and rebuilding runtimes.
812
+ Use ``stopped_application`` to validate and snapshot an initialized source.
813
+ """
814
+ home = _home(home)
815
+ directory = _path(home, ".toolframe", required=False)
816
+ if directory.exists() and not directory.is_dir():
817
+ _fail(message="The application lock directory must be a regular directory")
818
+ lock_path = _path(home, ".toolframe/host.lock", required=False)
819
+ if lock_path.exists() and not lock_path.is_file():
820
+ _fail(message="The application lock must be a regular file")
821
+ lock = HostLock(home)
822
+ try:
823
+ try:
824
+ lock.acquire()
825
+ except OSError:
826
+ _fail(message="The application lock cannot be acquired")
827
+ yield None
828
+ finally:
829
+ lock.close()
830
+
831
+
832
+ @contextmanager
833
+ def stopped_application(home: str | Path):
834
+ """Hold the real HostLock while inventorying and snapshotting a stopped home.
835
+
836
+ Raises ``PluginError`` for running, uncertain, external, incomplete or unsafe
837
+ layouts. This never starts workers, repairs state, or migrates databases.
838
+ ``home`` must have completed a Core startup and clean shutdown.
839
+ """
840
+ home = _home(home)
841
+ if StateSettings.from_env().backend != "sqlite":
842
+ _fail(
843
+ "maintenance_external_state",
844
+ "External Core state is unsupported by local maintenance",
845
+ )
846
+ for relative in (
847
+ "toolframe.toml",
848
+ "toolframe.lock",
849
+ "data/core.sqlite",
850
+ ".toolframe/management.token",
851
+ ):
852
+ _path(home, relative)
853
+ with application_lock(home):
854
+ try:
855
+ inventory = _inventory(home)
856
+ except (
857
+ OSError,
858
+ ValueError,
859
+ TypeError,
860
+ KeyError,
861
+ AttributeError,
862
+ sqlite3.Error,
863
+ RecursionError,
864
+ ):
865
+ # Parser/driver errors can contain stored credentials; expose only
866
+ # the stable operator error, never persisted values or SQL rows.
867
+ _fail()
868
+ yield inventory