shellbrain 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. app/__init__.py +1 -0
  2. app/__main__.py +7 -0
  3. app/boot/__init__.py +1 -0
  4. app/boot/admin_db.py +88 -0
  5. app/boot/config.py +14 -0
  6. app/boot/create_policy.py +52 -0
  7. app/boot/db.py +70 -0
  8. app/boot/embeddings.py +55 -0
  9. app/boot/home.py +45 -0
  10. app/boot/migrations.py +61 -0
  11. app/boot/read_policy.py +179 -0
  12. app/boot/repos.py +15 -0
  13. app/boot/retrieval.py +3 -0
  14. app/boot/thresholds.py +19 -0
  15. app/boot/update_policy.py +34 -0
  16. app/boot/use_cases.py +22 -0
  17. app/config/__init__.py +1 -0
  18. app/config/defaults/create_policy.yaml +7 -0
  19. app/config/defaults/read_policy.yaml +25 -0
  20. app/config/defaults/runtime.yaml +10 -0
  21. app/config/defaults/thresholds.yaml +3 -0
  22. app/config/defaults/update_policy.yaml +5 -0
  23. app/config/loader.py +58 -0
  24. app/core/__init__.py +1 -0
  25. app/core/contracts/__init__.py +1 -0
  26. app/core/contracts/errors.py +29 -0
  27. app/core/contracts/requests.py +211 -0
  28. app/core/contracts/responses.py +15 -0
  29. app/core/entities/__init__.py +1 -0
  30. app/core/entities/associations.py +58 -0
  31. app/core/entities/episodes.py +66 -0
  32. app/core/entities/evidence.py +29 -0
  33. app/core/entities/facts.py +30 -0
  34. app/core/entities/guidance.py +47 -0
  35. app/core/entities/identity.py +48 -0
  36. app/core/entities/memory.py +34 -0
  37. app/core/entities/runtime_context.py +19 -0
  38. app/core/entities/session_state.py +31 -0
  39. app/core/entities/telemetry.py +152 -0
  40. app/core/entities/utility.py +14 -0
  41. app/core/interfaces/__init__.py +1 -0
  42. app/core/interfaces/clock.py +12 -0
  43. app/core/interfaces/config.py +28 -0
  44. app/core/interfaces/embeddings.py +12 -0
  45. app/core/interfaces/idgen.py +11 -0
  46. app/core/interfaces/repos.py +279 -0
  47. app/core/interfaces/retrieval.py +20 -0
  48. app/core/interfaces/session_state_store.py +33 -0
  49. app/core/interfaces/unit_of_work.py +50 -0
  50. app/core/policies/__init__.py +1 -0
  51. app/core/policies/_shared/__init__.py +1 -0
  52. app/core/policies/_shared/executor.py +132 -0
  53. app/core/policies/_shared/side_effects.py +9 -0
  54. app/core/policies/create_policy/__init__.py +1 -0
  55. app/core/policies/create_policy/pipeline.py +96 -0
  56. app/core/policies/read_policy/__init__.py +1 -0
  57. app/core/policies/read_policy/bm25.py +114 -0
  58. app/core/policies/read_policy/context_pack_builder.py +140 -0
  59. app/core/policies/read_policy/expansion.py +132 -0
  60. app/core/policies/read_policy/fusion_rrf.py +34 -0
  61. app/core/policies/read_policy/lexical_query.py +101 -0
  62. app/core/policies/read_policy/pipeline.py +93 -0
  63. app/core/policies/read_policy/scenario_lift.py +11 -0
  64. app/core/policies/read_policy/scoring.py +61 -0
  65. app/core/policies/read_policy/seed_retrieval.py +54 -0
  66. app/core/policies/read_policy/utility_prior.py +11 -0
  67. app/core/policies/update_policy/__init__.py +1 -0
  68. app/core/policies/update_policy/pipeline.py +80 -0
  69. app/core/use_cases/__init__.py +1 -0
  70. app/core/use_cases/build_guidance.py +85 -0
  71. app/core/use_cases/create_memory.py +26 -0
  72. app/core/use_cases/manage_session_state.py +159 -0
  73. app/core/use_cases/read_memory.py +21 -0
  74. app/core/use_cases/record_episode_sync_telemetry.py +19 -0
  75. app/core/use_cases/record_operation_telemetry.py +32 -0
  76. app/core/use_cases/sync_episode.py +162 -0
  77. app/core/use_cases/update_memory.py +40 -0
  78. app/migrations/__init__.py +1 -0
  79. app/migrations/env.py +65 -0
  80. app/migrations/versions/20260226_0001_initial_schema.py +232 -0
  81. app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
  82. app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
  83. app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
  84. app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
  85. app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
  86. app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
  87. app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
  88. app/migrations/versions/__init__.py +1 -0
  89. app/periphery/__init__.py +1 -0
  90. app/periphery/admin/__init__.py +1 -0
  91. app/periphery/admin/backup.py +360 -0
  92. app/periphery/admin/destructive_guard.py +32 -0
  93. app/periphery/admin/doctor.py +192 -0
  94. app/periphery/admin/init.py +996 -0
  95. app/periphery/admin/instance_guard.py +211 -0
  96. app/periphery/admin/machine_state.py +354 -0
  97. app/periphery/admin/privileges.py +42 -0
  98. app/periphery/admin/repo_state.py +266 -0
  99. app/periphery/admin/restore.py +30 -0
  100. app/periphery/cli/__init__.py +1 -0
  101. app/periphery/cli/handlers.py +830 -0
  102. app/periphery/cli/hydration.py +119 -0
  103. app/periphery/cli/main.py +710 -0
  104. app/periphery/cli/presenter_json.py +10 -0
  105. app/periphery/cli/schema_validation.py +201 -0
  106. app/periphery/db/__init__.py +1 -0
  107. app/periphery/db/engine.py +10 -0
  108. app/periphery/db/models/__init__.py +1 -0
  109. app/periphery/db/models/associations.py +55 -0
  110. app/periphery/db/models/episodes.py +55 -0
  111. app/periphery/db/models/evidence.py +19 -0
  112. app/periphery/db/models/experiences.py +33 -0
  113. app/periphery/db/models/instance_metadata.py +17 -0
  114. app/periphery/db/models/memories.py +39 -0
  115. app/periphery/db/models/metadata.py +6 -0
  116. app/periphery/db/models/registry.py +18 -0
  117. app/periphery/db/models/telemetry.py +174 -0
  118. app/periphery/db/models/utility.py +19 -0
  119. app/periphery/db/models/views.py +154 -0
  120. app/periphery/db/repos/__init__.py +1 -0
  121. app/periphery/db/repos/relational/__init__.py +1 -0
  122. app/periphery/db/repos/relational/associations_repo.py +117 -0
  123. app/periphery/db/repos/relational/episodes_repo.py +188 -0
  124. app/periphery/db/repos/relational/evidence_repo.py +82 -0
  125. app/periphery/db/repos/relational/experiences_repo.py +41 -0
  126. app/periphery/db/repos/relational/memories_repo.py +99 -0
  127. app/periphery/db/repos/relational/read_policy_repo.py +202 -0
  128. app/periphery/db/repos/relational/telemetry_repo.py +161 -0
  129. app/periphery/db/repos/relational/utility_repo.py +30 -0
  130. app/periphery/db/repos/semantic/__init__.py +1 -0
  131. app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
  132. app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
  133. app/periphery/db/session.py +10 -0
  134. app/periphery/db/uow.py +75 -0
  135. app/periphery/embeddings/__init__.py +1 -0
  136. app/periphery/embeddings/local_provider.py +35 -0
  137. app/periphery/embeddings/query_vector_search.py +18 -0
  138. app/periphery/episodes/__init__.py +1 -0
  139. app/periphery/episodes/claude_code.py +387 -0
  140. app/periphery/episodes/codex.py +423 -0
  141. app/periphery/episodes/launcher.py +66 -0
  142. app/periphery/episodes/normalization.py +31 -0
  143. app/periphery/episodes/poller.py +299 -0
  144. app/periphery/episodes/source_discovery.py +66 -0
  145. app/periphery/episodes/tool_filter.py +165 -0
  146. app/periphery/identity/__init__.py +1 -0
  147. app/periphery/identity/claude_hook_install.py +67 -0
  148. app/periphery/identity/claude_runtime.py +83 -0
  149. app/periphery/identity/codex_runtime.py +32 -0
  150. app/periphery/identity/compatibility.py +38 -0
  151. app/periphery/identity/resolver.py +163 -0
  152. app/periphery/session_state/__init__.py +1 -0
  153. app/periphery/session_state/file_store.py +100 -0
  154. app/periphery/telemetry/__init__.py +33 -0
  155. app/periphery/telemetry/operation_summary.py +299 -0
  156. app/periphery/telemetry/session_selection.py +156 -0
  157. app/periphery/telemetry/sync_summary.py +65 -0
  158. app/periphery/validation/__init__.py +1 -0
  159. app/periphery/validation/integrity_validation.py +253 -0
  160. app/periphery/validation/semantic_validation.py +94 -0
  161. shellbrain-0.1.0.dist-info/METADATA +130 -0
  162. shellbrain-0.1.0.dist-info/RECORD +165 -0
  163. shellbrain-0.1.0.dist-info/WHEEL +5 -0
  164. shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
  165. shellbrain-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,31 @@
1
+ """Create instance metadata used for destructive guardrails and backup safety."""
2
+
3
+ from alembic import op
4
+
5
+
6
+ revision = "20260320_0008"
7
+ down_revision = "20260319_0007"
8
+ branch_labels = None
9
+ depends_on = None
10
+
11
+
12
+ def upgrade() -> None:
13
+ """Create the instance metadata table when missing."""
14
+
15
+ op.execute(
16
+ """
17
+ CREATE TABLE IF NOT EXISTS instance_metadata (
18
+ instance_id TEXT PRIMARY KEY,
19
+ instance_mode TEXT NOT NULL,
20
+ created_at TIMESTAMPTZ NOT NULL,
21
+ created_by TEXT NOT NULL,
22
+ notes TEXT NULL
23
+ );
24
+ """
25
+ )
26
+
27
+
28
+ def downgrade() -> None:
29
+ """Drop the instance metadata table for downgrade compatibility."""
30
+
31
+ op.execute("DROP TABLE IF EXISTS instance_metadata;")
@@ -0,0 +1 @@
1
+ """This package contains Alembic migration revision modules."""
@@ -0,0 +1 @@
1
+ """This package contains infrastructure adapters used by the core."""
@@ -0,0 +1 @@
1
+ """Administrative safety, backup, and recovery helpers."""
@@ -0,0 +1,360 @@
1
+ """Logical backup helpers for Shellbrain databases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import datetime, timezone
7
+ import gzip
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ from uuid import uuid4
15
+
16
+ import psycopg
17
+
18
+ from app.periphery.admin.instance_guard import PROTECTED_DB_NAMES, fetch_instance_metadata, fingerprint_summary
19
+
20
+
21
+ _UNSUPPORTED_RESTORE_PARAMETERS = ("transaction_timeout",)
22
+ _UNSUPPORTED_SET_LINE_RE = re.compile(r"^SET\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=")
23
+ _UNSUPPORTED_SET_CONFIG_RE = re.compile(r"^SELECT\s+pg_catalog\.set_config\('([a-zA-Z_][a-zA-Z0-9_]*)'")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class BackupManifest:
28
+ """Portable metadata stored next to one logical backup artifact."""
29
+
30
+ backup_id: str
31
+ instance_id: str
32
+ instance_mode: str
33
+ source: dict[str, str]
34
+ schema_revision: str
35
+ created_at: str
36
+ artifact_filename: str
37
+ artifact_sha256: str
38
+ artifact_size_bytes: int
39
+ compression: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class _ResolvedInstanceMetadata:
44
+ """Minimal metadata used to bucket and label backup artifacts."""
45
+
46
+ instance_id: str
47
+ instance_mode: str
48
+
49
+
50
+ def create_backup(
51
+ *,
52
+ admin_dsn: str,
53
+ backup_root: Path,
54
+ mirror_root: Path | None = None,
55
+ container_name: str | None = None,
56
+ container_db_name: str | None = None,
57
+ container_admin_user: str | None = None,
58
+ container_admin_password: str | None = None,
59
+ ) -> BackupManifest:
60
+ """Create one compressed logical backup and return its manifest."""
61
+
62
+ if container_name is None and shutil.which("pg_dump") is None:
63
+ raise RuntimeError("pg_dump is required to create Shellbrain backups.")
64
+ metadata = _resolve_instance_metadata(admin_dsn)
65
+ schema_revision = _fetch_schema_revision(admin_dsn)
66
+ created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
67
+ backup_id = uuid4().hex
68
+ instance_dir = backup_root / metadata.instance_id
69
+ instance_dir.mkdir(parents=True, exist_ok=True)
70
+ artifact_path = instance_dir / f"{created_at}-{backup_id}.sql.gz"
71
+ manifest_path = instance_dir / f"{created_at}-{backup_id}.manifest.json"
72
+ raw_dsn = admin_dsn.replace("+psycopg", "")
73
+
74
+ command = _backup_command(
75
+ admin_dsn=admin_dsn,
76
+ container_name=container_name,
77
+ container_db_name=container_db_name,
78
+ container_admin_user=container_admin_user,
79
+ )
80
+ env = _backup_env(
81
+ container_admin_password=container_admin_password,
82
+ )
83
+ popen_kwargs = {
84
+ "stdout": subprocess.PIPE,
85
+ "stderr": subprocess.PIPE,
86
+ "text": False,
87
+ }
88
+ if env is not None:
89
+ popen_kwargs["env"] = env
90
+ with subprocess.Popen(command, **popen_kwargs) as process:
91
+ stdout, stderr = process.communicate()
92
+ if process.returncode != 0:
93
+ raise RuntimeError(f"pg_dump failed: {stderr.decode('utf-8', errors='replace').strip()}")
94
+ with gzip.open(artifact_path, "wb") as handle:
95
+ handle.write(stdout)
96
+
97
+ manifest = BackupManifest(
98
+ backup_id=backup_id,
99
+ instance_id=metadata.instance_id,
100
+ instance_mode=metadata.instance_mode,
101
+ source=fingerprint_summary(admin_dsn),
102
+ schema_revision=schema_revision,
103
+ created_at=datetime.now(timezone.utc).isoformat(),
104
+ artifact_filename=artifact_path.name,
105
+ artifact_sha256=_sha256(artifact_path),
106
+ artifact_size_bytes=artifact_path.stat().st_size,
107
+ compression="gzip",
108
+ )
109
+ manifest_path.write_text(json.dumps(asdict(manifest), indent=2, sort_keys=True), encoding="utf-8")
110
+
111
+ if mirror_root is not None:
112
+ mirror_dir = mirror_root / metadata.instance_id
113
+ mirror_dir.mkdir(parents=True, exist_ok=True)
114
+ shutil.copy2(artifact_path, mirror_dir / artifact_path.name)
115
+ shutil.copy2(manifest_path, mirror_dir / manifest_path.name)
116
+ return manifest
117
+
118
+
119
+ def list_backups(*, backup_root: Path) -> list[BackupManifest]:
120
+ """Return every parseable backup manifest under the configured backup root."""
121
+
122
+ if not backup_root.exists():
123
+ return []
124
+ manifests: list[BackupManifest] = []
125
+ for path in sorted(backup_root.rglob("*.manifest.json")):
126
+ try:
127
+ payload = json.loads(path.read_text(encoding="utf-8"))
128
+ except (FileNotFoundError, json.JSONDecodeError):
129
+ continue
130
+ manifests.append(BackupManifest(**payload))
131
+ return sorted(manifests, key=lambda item: item.created_at, reverse=True)
132
+
133
+
134
+ def verify_backup(*, backup_root: Path, backup_id: str | None = None) -> BackupManifest:
135
+ """Verify one backup manifest and artifact hash, defaulting to the newest artifact."""
136
+
137
+ manifest = resolve_backup(backup_root=backup_root, backup_id=backup_id)
138
+ artifact_path = backup_root / manifest.instance_id / manifest.artifact_filename
139
+ if not artifact_path.exists():
140
+ raise RuntimeError(f"Backup artifact is missing: {artifact_path}")
141
+ actual_sha256 = _sha256(artifact_path)
142
+ if actual_sha256 != manifest.artifact_sha256:
143
+ raise RuntimeError("Backup artifact hash mismatch.")
144
+ return manifest
145
+
146
+
147
+ def resolve_backup(*, backup_root: Path, backup_id: str | None = None) -> BackupManifest:
148
+ """Resolve one manifest by id, defaulting to the newest available backup."""
149
+
150
+ manifests = list_backups(backup_root=backup_root)
151
+ if not manifests:
152
+ raise RuntimeError("No Shellbrain backups are available.")
153
+ if backup_id is None:
154
+ return manifests[0]
155
+ for manifest in manifests:
156
+ if manifest.backup_id == backup_id:
157
+ return manifest
158
+ raise RuntimeError(f"Backup id not found: {backup_id}")
159
+
160
+
161
+ def restore_backup(
162
+ *,
163
+ admin_dsn: str,
164
+ backup_root: Path,
165
+ target_db: str,
166
+ app_dsn: str | None = None,
167
+ backup_id: str | None = None,
168
+ container_name: str | None = None,
169
+ container_admin_user: str | None = None,
170
+ container_admin_password: str | None = None,
171
+ ) -> BackupManifest:
172
+ """Restore one verified backup into a new scratch database."""
173
+
174
+ if container_name is None and shutil.which("psql") is None:
175
+ raise RuntimeError("psql is required to restore Shellbrain backups.")
176
+ if target_db.lower() in PROTECTED_DB_NAMES:
177
+ raise RuntimeError(
178
+ f"Refusing to restore into protected database name '{target_db}'. Use a fresh scratch database name."
179
+ )
180
+ manifest = verify_backup(backup_root=backup_root, backup_id=backup_id)
181
+ artifact_path = backup_root / manifest.instance_id / manifest.artifact_filename
182
+ raw_admin_dsn = admin_dsn.replace("+psycopg", "")
183
+ _create_empty_database(admin_dsn=admin_dsn, target_db=target_db)
184
+ target_dsn = _replace_database(raw_admin_dsn, target_db)
185
+ with gzip.open(artifact_path, "rb") as handle:
186
+ restored_sql = _sanitize_restore_sql(handle.read())
187
+ run_kwargs = {
188
+ "input": restored_sql,
189
+ "capture_output": True,
190
+ "check": False,
191
+ }
192
+ env = _backup_env(container_admin_password=container_admin_password)
193
+ if env is not None:
194
+ run_kwargs["env"] = env
195
+ process = subprocess.run(
196
+ _restore_command(
197
+ target_dsn=target_dsn,
198
+ target_db=target_db,
199
+ container_name=container_name,
200
+ container_admin_user=container_admin_user,
201
+ ),
202
+ **run_kwargs,
203
+ )
204
+ if process.returncode != 0:
205
+ raise RuntimeError(process.stderr.decode("utf-8", errors="replace").strip() or "psql restore failed")
206
+ from app.periphery.admin.instance_guard import ensure_instance_metadata
207
+
208
+ target_admin_dsn = _replace_database(admin_dsn, target_db)
209
+ ensure_instance_metadata(
210
+ target_admin_dsn,
211
+ instance_mode="scratch",
212
+ created_by="app.admin.restore",
213
+ notes=f"Restored from backup {manifest.backup_id}",
214
+ )
215
+ if app_dsn:
216
+ from app.periphery.admin.privileges import reconcile_app_role_privileges
217
+
218
+ target_app_dsn = _replace_database(app_dsn, target_db)
219
+ reconcile_app_role_privileges(admin_dsn=target_admin_dsn, app_dsn=target_app_dsn)
220
+ return manifest
221
+
222
+
223
+ def _resolve_instance_metadata(admin_dsn: str) -> _ResolvedInstanceMetadata:
224
+ """Resolve stored instance metadata, or synthesize a stable fallback label."""
225
+
226
+ metadata = fetch_instance_metadata(admin_dsn)
227
+ if metadata is not None:
228
+ return _ResolvedInstanceMetadata(
229
+ instance_id=metadata.instance_id,
230
+ instance_mode=metadata.instance_mode,
231
+ )
232
+ source = fingerprint_summary(admin_dsn)
233
+ return _ResolvedInstanceMetadata(
234
+ instance_id=source["fingerprint"],
235
+ instance_mode="unknown",
236
+ )
237
+
238
+
239
+ def _backup_command(
240
+ *,
241
+ admin_dsn: str,
242
+ container_name: str | None,
243
+ container_db_name: str | None,
244
+ container_admin_user: str | None,
245
+ ) -> list[str]:
246
+ """Return the pg_dump command for host or managed-container execution."""
247
+
248
+ if container_name is None:
249
+ raw_dsn = admin_dsn.replace("+psycopg", "")
250
+ return ["pg_dump", "--no-owner", "--no-privileges", f"--dbname={raw_dsn}"]
251
+ if not container_db_name or not container_admin_user:
252
+ raise RuntimeError("Managed container backups require container DB name and admin user.")
253
+ return [
254
+ "docker",
255
+ "exec",
256
+ "-e",
257
+ "PGPASSWORD",
258
+ container_name,
259
+ "pg_dump",
260
+ "--no-owner",
261
+ "--no-privileges",
262
+ "--username",
263
+ container_admin_user,
264
+ "--dbname",
265
+ container_db_name,
266
+ ]
267
+
268
+
269
+ def _restore_command(
270
+ *,
271
+ target_dsn: str,
272
+ target_db: str,
273
+ container_name: str | None,
274
+ container_admin_user: str | None,
275
+ ) -> list[str]:
276
+ """Return the psql command for host or managed-container restore execution."""
277
+
278
+ if container_name is None:
279
+ return ["psql", "--set", "ON_ERROR_STOP=1", f"--dbname={target_dsn}"]
280
+ if not container_admin_user:
281
+ raise RuntimeError("Managed container restore requires the container admin user.")
282
+ return [
283
+ "docker",
284
+ "exec",
285
+ "-i",
286
+ "-e",
287
+ "PGPASSWORD",
288
+ container_name,
289
+ "psql",
290
+ "--set",
291
+ "ON_ERROR_STOP=1",
292
+ "--username",
293
+ container_admin_user,
294
+ "--dbname",
295
+ target_db,
296
+ ]
297
+
298
+
299
+ def _backup_env(*, container_admin_password: str | None) -> dict[str, str] | None:
300
+ """Return subprocess environment overrides for managed-container operations."""
301
+
302
+ if container_admin_password is None:
303
+ return None
304
+ return {"PGPASSWORD": container_admin_password}
305
+
306
+
307
+ def _create_empty_database(*, admin_dsn: str, target_db: str) -> None:
308
+ """Create one empty restore target database, failing if it already exists."""
309
+
310
+ raw_admin_dsn = admin_dsn.replace("+psycopg", "")
311
+ postgres_dsn = _replace_database(raw_admin_dsn, "postgres")
312
+ with psycopg.connect(postgres_dsn, autocommit=True) as conn:
313
+ with conn.cursor() as cur:
314
+ cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (target_db,))
315
+ if cur.fetchone() is not None:
316
+ raise RuntimeError(f"Target database already exists: {target_db}")
317
+ cur.execute(f'CREATE DATABASE "{target_db}"')
318
+
319
+
320
+ def _fetch_schema_revision(admin_dsn: str) -> str:
321
+ """Read the current alembic revision for manifest metadata."""
322
+
323
+ raw_dsn = admin_dsn.replace("+psycopg", "")
324
+ with psycopg.connect(raw_dsn) as conn:
325
+ with conn.cursor() as cur:
326
+ cur.execute("SELECT version_num FROM alembic_version")
327
+ return str(cur.fetchone()[0])
328
+
329
+
330
+ def _replace_database(dsn: str, db_name: str) -> str:
331
+ """Replace the database path portion of one DSN string."""
332
+
333
+ prefix, _, _ = dsn.rpartition("/")
334
+ return f"{prefix}/{db_name}"
335
+
336
+
337
+ def _sha256(path: Path) -> str:
338
+ """Compute one stable SHA-256 hash for a file."""
339
+
340
+ digest = hashlib.sha256()
341
+ with path.open("rb") as handle:
342
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
343
+ digest.update(chunk)
344
+ return digest.hexdigest()
345
+
346
+
347
+ def _sanitize_restore_sql(dump_bytes: bytes) -> bytes:
348
+ """Strip session settings that newer pg_dump versions emit but older servers reject."""
349
+
350
+ filtered: list[bytes] = []
351
+ for line in dump_bytes.splitlines(keepends=True):
352
+ decoded = line.decode("utf-8", errors="replace").strip()
353
+ set_match = _UNSUPPORTED_SET_LINE_RE.match(decoded)
354
+ if set_match and set_match.group(1) in _UNSUPPORTED_RESTORE_PARAMETERS:
355
+ continue
356
+ set_config_match = _UNSUPPORTED_SET_CONFIG_RE.match(decoded)
357
+ if set_config_match and set_config_match.group(1) in _UNSUPPORTED_RESTORE_PARAMETERS:
358
+ continue
359
+ filtered.append(line)
360
+ return b"".join(filtered)
@@ -0,0 +1,32 @@
1
+ """Shared guardrails for official destructive Shellbrain admin operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from app.periphery.admin.backup import BackupManifest, create_backup, verify_backup
8
+
9
+
10
+ def backup_and_verify_before_destructive_action(
11
+ *,
12
+ admin_dsn: str,
13
+ backup_root: Path,
14
+ mirror_root: Path | None = None,
15
+ container_name: str | None = None,
16
+ container_db_name: str | None = None,
17
+ container_admin_user: str | None = None,
18
+ container_admin_password: str | None = None,
19
+ ) -> BackupManifest:
20
+ """Create and verify one backup before an official destructive admin mutation."""
21
+
22
+ manifest = create_backup(
23
+ admin_dsn=admin_dsn,
24
+ backup_root=backup_root,
25
+ mirror_root=mirror_root,
26
+ container_name=container_name,
27
+ container_db_name=container_db_name,
28
+ container_admin_user=container_admin_user,
29
+ container_admin_password=container_admin_password,
30
+ )
31
+ verify_backup(backup_root=backup_root, backup_id=manifest.backup_id)
32
+ return manifest
@@ -0,0 +1,192 @@
1
+ """Operational diagnostics for Shellbrain safety posture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import json
7
+ from pathlib import Path
8
+ import shutil
9
+ from typing import Any
10
+
11
+ import psycopg
12
+
13
+ from app.boot.home import get_shellbrain_home
14
+ from app.periphery.admin.backup import list_backups
15
+ from app.periphery.admin.instance_guard import fetch_instance_metadata, inspect_role_safety
16
+ from app.periphery.admin.machine_state import MachineConfig, try_load_machine_config
17
+ from app.periphery.admin.repo_state import IDENTITY_STRENGTH_WEAK_LOCAL, load_repo_registration, resolve_git_root
18
+
19
+ _LOW_DISK_WARNING_BYTES = 2 * 1024 * 1024 * 1024
20
+
21
+
22
+ def build_doctor_report(
23
+ *,
24
+ app_dsn: str | None,
25
+ admin_dsn: str | None,
26
+ backup_root: Path,
27
+ repo_root: Path | None = None,
28
+ ) -> dict[str, Any]:
29
+ """Return one structured safety report for the current Shellbrain environment."""
30
+
31
+ machine_config, machine_error = try_load_machine_config()
32
+ instance = None
33
+ if app_dsn:
34
+ instance = fetch_instance_metadata(app_dsn)
35
+ if instance is None and admin_dsn:
36
+ instance = fetch_instance_metadata(admin_dsn)
37
+ backups = list_backups(backup_root=backup_root)
38
+ app_role_warnings = [] if not app_dsn else inspect_role_safety(app_dsn)
39
+ admin_role_warnings = [] if not admin_dsn else inspect_role_safety(admin_dsn)
40
+ checked_at = datetime.now(timezone.utc)
41
+ latest_backup = None if not backups else json.loads(json.dumps(backups[0].__dict__))
42
+ backup_age_seconds = None
43
+ if backups:
44
+ created_at = datetime.fromisoformat(backups[0].created_at.replace("Z", "+00:00"))
45
+ backup_age_seconds = int((checked_at - created_at.astimezone(timezone.utc)).total_seconds())
46
+ home_root = get_shellbrain_home()
47
+ disk = shutil.disk_usage(home_root if home_root.exists() else home_root.parent)
48
+ repo_report = _build_repo_report(repo_root=repo_root)
49
+
50
+ report: dict[str, Any] = {
51
+ "checked_at": checked_at.isoformat(),
52
+ "shellbrain_home": str(home_root),
53
+ "config_status": _config_status(machine_config=machine_config, machine_error=machine_error),
54
+ "config_error": machine_error,
55
+ "runtime_mode": None if machine_config is None else machine_config.runtime_mode,
56
+ "bootstrap_state": None if machine_config is None else machine_config.bootstrap_state,
57
+ "current_step": None if machine_config is None else machine_config.current_step,
58
+ "last_error": None if machine_config is None else machine_config.last_error,
59
+ "config_version": None if machine_config is None else machine_config.config_version,
60
+ "bootstrap_version": None if machine_config is None else machine_config.bootstrap_version,
61
+ "machine_instance": _machine_instance_report(machine_config),
62
+ "effective_config": _effective_config_summary(machine_config=machine_config, app_dsn=app_dsn, admin_dsn=admin_dsn),
63
+ "app_dsn_configured": bool(app_dsn),
64
+ "admin_dsn_configured": bool(admin_dsn),
65
+ "instance": None if instance is None else instance.__dict__,
66
+ "app_role_warnings": app_role_warnings,
67
+ "admin_role_warnings": admin_role_warnings,
68
+ "schema_revision": None if not app_dsn else _fetch_schema_revision(app_dsn),
69
+ "backup_root": str(backup_root),
70
+ "backup_count": len(backups),
71
+ "latest_backup": latest_backup,
72
+ "backup_age_seconds": backup_age_seconds,
73
+ "disk_free_bytes": disk.free,
74
+ "disk_warning": None if disk.free >= _LOW_DISK_WARNING_BYTES else "Low free disk space under Shellbrain home.",
75
+ "repo": repo_report,
76
+ }
77
+ if repo_report and repo_report.get("identity_strength") == IDENTITY_STRENGTH_WEAK_LOCAL:
78
+ report["repo_warning"] = "Repo identity is weak-local and will change if this directory moves."
79
+ return report
80
+
81
+
82
+ def _build_repo_report(*, repo_root: Path | None) -> dict[str, Any] | None:
83
+ """Return repo-local registration status when the target looks repo-shaped."""
84
+
85
+ if repo_root is None:
86
+ return None
87
+ target = Path(repo_root).expanduser().resolve()
88
+ git_root = resolve_git_root(target)
89
+ registration_root = git_root or target
90
+ registration = load_repo_registration(registration_root)
91
+ if registration is None and git_root is None and not (target / ".shellbrain").exists():
92
+ return None
93
+ return {
94
+ "repo_root": str(target),
95
+ "git_root": str(git_root) if git_root is not None else None,
96
+ "registered": registration is not None,
97
+ "repo_id": None if registration is None else registration.repo_id,
98
+ "identity_strength": None if registration is None else registration.identity_strength,
99
+ "source_remote": None if registration is None else registration.source_remote,
100
+ "machine_instance_id": None if registration is None else registration.machine_instance_id,
101
+ "registered_at": None if registration is None else registration.registered_at,
102
+ "claude_status": None if registration is None else registration.claude_status,
103
+ "claude_settings_path": None if registration is None else registration.claude_settings_path,
104
+ "claude_note": None if registration is None else registration.claude_note,
105
+ }
106
+
107
+
108
+ def _config_status(*, machine_config: MachineConfig | None, machine_error: str | None) -> str:
109
+ """Return one short machine config health label."""
110
+
111
+ if machine_error:
112
+ return "corrupt"
113
+ if machine_config is not None:
114
+ return "ok"
115
+ return "absent"
116
+
117
+
118
+ def _machine_instance_report(machine_config: MachineConfig | None) -> dict[str, Any] | None:
119
+ """Return managed-instance details when machine config exists."""
120
+
121
+ if machine_config is None:
122
+ return None
123
+ return {
124
+ "instance_id": machine_config.machine_instance_id,
125
+ "container_name": machine_config.managed.container_name,
126
+ "image": machine_config.managed.image,
127
+ "host": machine_config.managed.host,
128
+ "port": machine_config.managed.port,
129
+ "db_name": machine_config.managed.db_name,
130
+ "data_dir": machine_config.managed.data_dir,
131
+ "backup_root": machine_config.backups.root,
132
+ "embeddings": {
133
+ "provider": machine_config.embeddings.provider,
134
+ "model": machine_config.embeddings.model,
135
+ "model_revision": machine_config.embeddings.model_revision,
136
+ "backend_version": machine_config.embeddings.backend_version,
137
+ "cache_path": machine_config.embeddings.cache_path,
138
+ "readiness_state": machine_config.embeddings.readiness_state,
139
+ "last_error": machine_config.embeddings.last_error,
140
+ },
141
+ }
142
+
143
+
144
+ def _effective_config_summary(
145
+ *,
146
+ machine_config: MachineConfig | None,
147
+ app_dsn: str | None,
148
+ admin_dsn: str | None,
149
+ ) -> dict[str, Any]:
150
+ """Return a redacted summary of the effective runtime config."""
151
+
152
+ if machine_config is not None:
153
+ return {
154
+ "source": "machine_config",
155
+ "app_dsn": _redact_dsn(machine_config.database.app_dsn),
156
+ "admin_dsn": _redact_dsn(machine_config.database.admin_dsn),
157
+ "runtime_mode": machine_config.runtime_mode,
158
+ "backup_root": machine_config.backups.root,
159
+ }
160
+ return {
161
+ "source": "legacy_env",
162
+ "app_dsn": _redact_dsn(app_dsn),
163
+ "admin_dsn": _redact_dsn(admin_dsn),
164
+ "runtime_mode": None,
165
+ "backup_root": None,
166
+ }
167
+
168
+
169
+ def _redact_dsn(dsn: str | None) -> str | None:
170
+ """Return a redacted DSN for diagnostics."""
171
+
172
+ if not dsn:
173
+ return None
174
+ raw = dsn.replace("+psycopg", "")
175
+ prefix, at_sign, host_part = raw.rpartition("@")
176
+ if not at_sign:
177
+ return "<redacted>"
178
+ user_prefix, _, _ = prefix.partition("://")
179
+ scheme = prefix.split("://", 1)[0]
180
+ return f"{scheme}://<redacted>@{host_part}"
181
+
182
+
183
+ def _fetch_schema_revision(dsn: str) -> str | None:
184
+ """Best-effort read of the current alembic revision."""
185
+
186
+ try:
187
+ with psycopg.connect(dsn.replace("+psycopg", "")) as conn:
188
+ with conn.cursor() as cur:
189
+ cur.execute("SELECT version_num FROM alembic_version")
190
+ return str(cur.fetchone()[0])
191
+ except psycopg.Error:
192
+ return None