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.
- app/__init__.py +1 -0
- app/__main__.py +7 -0
- app/boot/__init__.py +1 -0
- app/boot/admin_db.py +88 -0
- app/boot/config.py +14 -0
- app/boot/create_policy.py +52 -0
- app/boot/db.py +70 -0
- app/boot/embeddings.py +55 -0
- app/boot/home.py +45 -0
- app/boot/migrations.py +61 -0
- app/boot/read_policy.py +179 -0
- app/boot/repos.py +15 -0
- app/boot/retrieval.py +3 -0
- app/boot/thresholds.py +19 -0
- app/boot/update_policy.py +34 -0
- app/boot/use_cases.py +22 -0
- app/config/__init__.py +1 -0
- app/config/defaults/create_policy.yaml +7 -0
- app/config/defaults/read_policy.yaml +25 -0
- app/config/defaults/runtime.yaml +10 -0
- app/config/defaults/thresholds.yaml +3 -0
- app/config/defaults/update_policy.yaml +5 -0
- app/config/loader.py +58 -0
- app/core/__init__.py +1 -0
- app/core/contracts/__init__.py +1 -0
- app/core/contracts/errors.py +29 -0
- app/core/contracts/requests.py +211 -0
- app/core/contracts/responses.py +15 -0
- app/core/entities/__init__.py +1 -0
- app/core/entities/associations.py +58 -0
- app/core/entities/episodes.py +66 -0
- app/core/entities/evidence.py +29 -0
- app/core/entities/facts.py +30 -0
- app/core/entities/guidance.py +47 -0
- app/core/entities/identity.py +48 -0
- app/core/entities/memory.py +34 -0
- app/core/entities/runtime_context.py +19 -0
- app/core/entities/session_state.py +31 -0
- app/core/entities/telemetry.py +152 -0
- app/core/entities/utility.py +14 -0
- app/core/interfaces/__init__.py +1 -0
- app/core/interfaces/clock.py +12 -0
- app/core/interfaces/config.py +28 -0
- app/core/interfaces/embeddings.py +12 -0
- app/core/interfaces/idgen.py +11 -0
- app/core/interfaces/repos.py +279 -0
- app/core/interfaces/retrieval.py +20 -0
- app/core/interfaces/session_state_store.py +33 -0
- app/core/interfaces/unit_of_work.py +50 -0
- app/core/policies/__init__.py +1 -0
- app/core/policies/_shared/__init__.py +1 -0
- app/core/policies/_shared/executor.py +132 -0
- app/core/policies/_shared/side_effects.py +9 -0
- app/core/policies/create_policy/__init__.py +1 -0
- app/core/policies/create_policy/pipeline.py +96 -0
- app/core/policies/read_policy/__init__.py +1 -0
- app/core/policies/read_policy/bm25.py +114 -0
- app/core/policies/read_policy/context_pack_builder.py +140 -0
- app/core/policies/read_policy/expansion.py +132 -0
- app/core/policies/read_policy/fusion_rrf.py +34 -0
- app/core/policies/read_policy/lexical_query.py +101 -0
- app/core/policies/read_policy/pipeline.py +93 -0
- app/core/policies/read_policy/scenario_lift.py +11 -0
- app/core/policies/read_policy/scoring.py +61 -0
- app/core/policies/read_policy/seed_retrieval.py +54 -0
- app/core/policies/read_policy/utility_prior.py +11 -0
- app/core/policies/update_policy/__init__.py +1 -0
- app/core/policies/update_policy/pipeline.py +80 -0
- app/core/use_cases/__init__.py +1 -0
- app/core/use_cases/build_guidance.py +85 -0
- app/core/use_cases/create_memory.py +26 -0
- app/core/use_cases/manage_session_state.py +159 -0
- app/core/use_cases/read_memory.py +21 -0
- app/core/use_cases/record_episode_sync_telemetry.py +19 -0
- app/core/use_cases/record_operation_telemetry.py +32 -0
- app/core/use_cases/sync_episode.py +162 -0
- app/core/use_cases/update_memory.py +40 -0
- app/migrations/__init__.py +1 -0
- app/migrations/env.py +65 -0
- app/migrations/versions/20260226_0001_initial_schema.py +232 -0
- app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
- app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
- app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
- app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
- app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
- app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
- app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
- app/migrations/versions/__init__.py +1 -0
- app/periphery/__init__.py +1 -0
- app/periphery/admin/__init__.py +1 -0
- app/periphery/admin/backup.py +360 -0
- app/periphery/admin/destructive_guard.py +32 -0
- app/periphery/admin/doctor.py +192 -0
- app/periphery/admin/init.py +996 -0
- app/periphery/admin/instance_guard.py +211 -0
- app/periphery/admin/machine_state.py +354 -0
- app/periphery/admin/privileges.py +42 -0
- app/periphery/admin/repo_state.py +266 -0
- app/periphery/admin/restore.py +30 -0
- app/periphery/cli/__init__.py +1 -0
- app/periphery/cli/handlers.py +830 -0
- app/periphery/cli/hydration.py +119 -0
- app/periphery/cli/main.py +710 -0
- app/periphery/cli/presenter_json.py +10 -0
- app/periphery/cli/schema_validation.py +201 -0
- app/periphery/db/__init__.py +1 -0
- app/periphery/db/engine.py +10 -0
- app/periphery/db/models/__init__.py +1 -0
- app/periphery/db/models/associations.py +55 -0
- app/periphery/db/models/episodes.py +55 -0
- app/periphery/db/models/evidence.py +19 -0
- app/periphery/db/models/experiences.py +33 -0
- app/periphery/db/models/instance_metadata.py +17 -0
- app/periphery/db/models/memories.py +39 -0
- app/periphery/db/models/metadata.py +6 -0
- app/periphery/db/models/registry.py +18 -0
- app/periphery/db/models/telemetry.py +174 -0
- app/periphery/db/models/utility.py +19 -0
- app/periphery/db/models/views.py +154 -0
- app/periphery/db/repos/__init__.py +1 -0
- app/periphery/db/repos/relational/__init__.py +1 -0
- app/periphery/db/repos/relational/associations_repo.py +117 -0
- app/periphery/db/repos/relational/episodes_repo.py +188 -0
- app/periphery/db/repos/relational/evidence_repo.py +82 -0
- app/periphery/db/repos/relational/experiences_repo.py +41 -0
- app/periphery/db/repos/relational/memories_repo.py +99 -0
- app/periphery/db/repos/relational/read_policy_repo.py +202 -0
- app/periphery/db/repos/relational/telemetry_repo.py +161 -0
- app/periphery/db/repos/relational/utility_repo.py +30 -0
- app/periphery/db/repos/semantic/__init__.py +1 -0
- app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
- app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
- app/periphery/db/session.py +10 -0
- app/periphery/db/uow.py +75 -0
- app/periphery/embeddings/__init__.py +1 -0
- app/periphery/embeddings/local_provider.py +35 -0
- app/periphery/embeddings/query_vector_search.py +18 -0
- app/periphery/episodes/__init__.py +1 -0
- app/periphery/episodes/claude_code.py +387 -0
- app/periphery/episodes/codex.py +423 -0
- app/periphery/episodes/launcher.py +66 -0
- app/periphery/episodes/normalization.py +31 -0
- app/periphery/episodes/poller.py +299 -0
- app/periphery/episodes/source_discovery.py +66 -0
- app/periphery/episodes/tool_filter.py +165 -0
- app/periphery/identity/__init__.py +1 -0
- app/periphery/identity/claude_hook_install.py +67 -0
- app/periphery/identity/claude_runtime.py +83 -0
- app/periphery/identity/codex_runtime.py +32 -0
- app/periphery/identity/compatibility.py +38 -0
- app/periphery/identity/resolver.py +163 -0
- app/periphery/session_state/__init__.py +1 -0
- app/periphery/session_state/file_store.py +100 -0
- app/periphery/telemetry/__init__.py +33 -0
- app/periphery/telemetry/operation_summary.py +299 -0
- app/periphery/telemetry/session_selection.py +156 -0
- app/periphery/telemetry/sync_summary.py +65 -0
- app/periphery/validation/__init__.py +1 -0
- app/periphery/validation/integrity_validation.py +253 -0
- app/periphery/validation/semantic_validation.py +94 -0
- shellbrain-0.1.0.dist-info/METADATA +130 -0
- shellbrain-0.1.0.dist-info/RECORD +165 -0
- shellbrain-0.1.0.dist-info/WHEEL +5 -0
- shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|