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,211 @@
1
+ """Guards that classify DB instances and refuse destructive actions on live targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ import hashlib
8
+ from typing import Final
9
+ from urllib.parse import urlparse
10
+
11
+ import psycopg
12
+
13
+
14
+ LIVE: Final[str] = "live"
15
+ TEST: Final[str] = "test"
16
+ SCRATCH: Final[str] = "scratch"
17
+ ALLOWED_INSTANCE_MODES: Final[set[str]] = {LIVE, TEST, SCRATCH}
18
+ PROTECTED_DB_NAMES: Final[set[str]] = {"shellbrain", "memory"}
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class InstanceMetadataRecord:
23
+ """Minimal typed view of one instance_metadata row."""
24
+
25
+ instance_id: str
26
+ instance_mode: str
27
+ created_at: str
28
+ created_by: str
29
+ notes: str | None = None
30
+
31
+
32
+ def normalize_dsn(dsn: str) -> str:
33
+ """Normalize a DSN into a stable fingerprint source string."""
34
+
35
+ parsed = urlparse(dsn.replace("+psycopg", ""))
36
+ hostname = (parsed.hostname or "").lower()
37
+ port = parsed.port or 5432
38
+ db_name = parsed.path.lstrip("/")
39
+ return f"{hostname}:{port}/{db_name}"
40
+
41
+
42
+ def dsn_fingerprint(dsn: str) -> str:
43
+ """Hash a normalized DSN to compare protected targets without exposing passwords."""
44
+
45
+ return hashlib.sha256(normalize_dsn(dsn).encode("utf-8")).hexdigest()
46
+
47
+
48
+ def database_name_from_dsn(dsn: str) -> str:
49
+ """Extract the target database name from one DSN."""
50
+
51
+ return urlparse(dsn.replace("+psycopg", "")).path.lstrip("/")
52
+
53
+
54
+ def assert_disposable_test_dsn(*, test_dsn: str, protected_dsn: str | None = None) -> None:
55
+ """Refuse to treat a protected or production-shaped database as disposable."""
56
+
57
+ if protected_dsn and dsn_fingerprint(test_dsn) == dsn_fingerprint(protected_dsn):
58
+ raise RuntimeError("Refusing destructive test setup against the protected live database DSN.")
59
+ db_name = database_name_from_dsn(test_dsn).lower()
60
+ if db_name in PROTECTED_DB_NAMES:
61
+ raise RuntimeError(
62
+ f"Refusing destructive test setup against protected database '{db_name}'. "
63
+ "Use an explicitly disposable scratch/test database name."
64
+ )
65
+
66
+
67
+ def ensure_instance_metadata(
68
+ dsn: str,
69
+ *,
70
+ instance_mode: str,
71
+ created_by: str,
72
+ notes: str | None = None,
73
+ ) -> InstanceMetadataRecord:
74
+ """Create or preserve one instance_metadata row for the target database."""
75
+
76
+ if instance_mode not in ALLOWED_INSTANCE_MODES:
77
+ raise ValueError(f"Unsupported instance mode: {instance_mode}")
78
+ raw_dsn = dsn.replace("+psycopg", "")
79
+ instance_id = dsn_fingerprint(dsn)
80
+ created_at = datetime.now(timezone.utc).isoformat()
81
+ with psycopg.connect(raw_dsn) as conn:
82
+ with conn.cursor() as cur:
83
+ cur.execute(
84
+ """
85
+ CREATE TABLE IF NOT EXISTS instance_metadata (
86
+ instance_id TEXT PRIMARY KEY,
87
+ instance_mode TEXT NOT NULL,
88
+ created_at TIMESTAMPTZ NOT NULL,
89
+ created_by TEXT NOT NULL,
90
+ notes TEXT NULL
91
+ )
92
+ """
93
+ )
94
+ cur.execute(
95
+ """
96
+ INSERT INTO instance_metadata (instance_id, instance_mode, created_at, created_by, notes)
97
+ VALUES (%s, %s, %s, %s, %s)
98
+ ON CONFLICT (instance_id) DO NOTHING
99
+ """,
100
+ (instance_id, instance_mode, created_at, created_by, notes),
101
+ )
102
+ conn.commit()
103
+ record = fetch_instance_metadata(dsn, include_legacy_lookup=False)
104
+ if record is None:
105
+ raise RuntimeError("Failed to persist instance metadata.")
106
+ return record
107
+
108
+
109
+ def fetch_instance_metadata(dsn: str, *, include_legacy_lookup: bool = True) -> InstanceMetadataRecord | None:
110
+ """Read one instance_metadata row when the safety table exists."""
111
+
112
+ raw_dsn = dsn.replace("+psycopg", "")
113
+ instance_ids = [dsn_fingerprint(dsn)]
114
+ if include_legacy_lookup:
115
+ instance_ids.append(_legacy_dsn_fingerprint(dsn))
116
+ try:
117
+ with psycopg.connect(raw_dsn) as conn:
118
+ with conn.cursor() as cur:
119
+ cur.execute("SELECT to_regclass('public.instance_metadata');")
120
+ if cur.fetchone()[0] is None:
121
+ return None
122
+ row = None
123
+ for instance_id in instance_ids:
124
+ cur.execute(
125
+ """
126
+ SELECT instance_id, instance_mode, created_at::text, created_by, notes
127
+ FROM instance_metadata
128
+ WHERE instance_id = %s
129
+ """,
130
+ (instance_id,),
131
+ )
132
+ row = cur.fetchone()
133
+ if row is not None:
134
+ break
135
+ except psycopg.Error:
136
+ return None
137
+ if row is None:
138
+ return None
139
+ return InstanceMetadataRecord(*row)
140
+
141
+
142
+ def assert_destructive_allowed(dsn: str, *, allowed_modes: tuple[str, ...] = (TEST, SCRATCH)) -> None:
143
+ """Fail closed unless the target database is explicitly classified as disposable."""
144
+
145
+ record = fetch_instance_metadata(dsn)
146
+ if record is None:
147
+ raise RuntimeError(
148
+ "Refusing destructive action because instance metadata is missing. "
149
+ "Stamp the database as test or scratch first."
150
+ )
151
+ if record.instance_mode not in allowed_modes:
152
+ raise RuntimeError(
153
+ f"Refusing destructive action against instance_mode={record.instance_mode!r}. "
154
+ f"Allowed modes: {', '.join(allowed_modes)}."
155
+ )
156
+
157
+
158
+ def inspect_role_safety(dsn: str) -> list[str]:
159
+ """Return warnings when the provided DSN points at a dangerously privileged role."""
160
+
161
+ warnings: list[str] = []
162
+ raw_dsn = dsn.replace("+psycopg", "")
163
+ try:
164
+ with psycopg.connect(raw_dsn) as conn:
165
+ with conn.cursor() as cur:
166
+ cur.execute(
167
+ """
168
+ SELECT r.rolsuper,
169
+ pg_has_role(current_user, 'pg_database_owner', 'member'),
170
+ has_schema_privilege(current_user, 'public', 'CREATE')
171
+ FROM pg_roles r
172
+ WHERE r.rolname = current_user
173
+ """
174
+ )
175
+ row = cur.fetchone()
176
+ except psycopg.Error as exc:
177
+ return [f"Could not audit DB role safety: {exc}"]
178
+ if row is None:
179
+ return warnings
180
+ is_superuser, is_db_owner, can_create_in_public = row
181
+ if is_superuser:
182
+ warnings.append("Current DSN role is superuser-capable.")
183
+ if is_db_owner:
184
+ warnings.append("Current DSN role is a database owner.")
185
+ if can_create_in_public:
186
+ warnings.append("Current DSN role can CREATE in schema public.")
187
+ return warnings
188
+
189
+
190
+ def fingerprint_summary(dsn: str) -> dict[str, str]:
191
+ """Return one printable DB identity summary for diagnostics and manifests."""
192
+
193
+ parsed = urlparse(dsn.replace("+psycopg", ""))
194
+ return {
195
+ "fingerprint": dsn_fingerprint(dsn),
196
+ "host": parsed.hostname or "",
197
+ "port": str(parsed.port or 5432),
198
+ "database": parsed.path.lstrip("/"),
199
+ "user": parsed.username or "",
200
+ }
201
+
202
+
203
+ def _legacy_dsn_fingerprint(dsn: str) -> str:
204
+ """Compute the legacy fingerprint that also included the username."""
205
+
206
+ parsed = urlparse(dsn.replace("+psycopg", ""))
207
+ hostname = (parsed.hostname or "").lower()
208
+ port = parsed.port or 5432
209
+ db_name = parsed.path.lstrip("/")
210
+ user = parsed.username or ""
211
+ return hashlib.sha256(f"{hostname}:{port}/{db_name}?user={user}".encode("utf-8")).hexdigest()
@@ -0,0 +1,354 @@
1
+ """Machine-owned Shellbrain runtime config and bootstrap state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from datetime import datetime, timezone
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import tomllib
11
+ from typing import Any
12
+
13
+ from app.boot.home import (
14
+ get_machine_backups_dir,
15
+ get_machine_config_path,
16
+ get_machine_models_dir,
17
+ get_shellbrain_home,
18
+ )
19
+
20
+
21
+ CONFIG_VERSION = 1
22
+ BOOTSTRAP_VERSION = 1
23
+ RUNTIME_MODE_MANAGED_LOCAL = "managed_local"
24
+ BOOTSTRAP_STATE_PROVISIONING = "provisioning"
25
+ BOOTSTRAP_STATE_READY = "ready"
26
+ BOOTSTRAP_STATE_REPAIR_NEEDED = "repair_needed"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class DatabaseState:
31
+ """Resolved application and admin DSNs for the active runtime."""
32
+
33
+ app_dsn: str
34
+ admin_dsn: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ManagedInstanceState:
39
+ """Machine-owned managed Postgres runtime metadata."""
40
+
41
+ instance_id: str
42
+ container_name: str
43
+ image: str
44
+ host: str
45
+ port: int
46
+ db_name: str
47
+ data_dir: str
48
+ admin_user: str
49
+ admin_password: str
50
+ app_user: str
51
+ app_password: str
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class BackupState:
56
+ """Configured backup directory roots."""
57
+
58
+ root: str
59
+ mirror_root: str | None = None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class EmbeddingRuntimeState:
64
+ """Pinned embedding runtime metadata."""
65
+
66
+ provider: str
67
+ model: str
68
+ model_revision: str | None
69
+ backend_version: str | None
70
+ cache_path: str
71
+ readiness_state: str
72
+ last_error: str | None = None
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class MachineConfig:
77
+ """Machine-owned runtime state for Shellbrain bootstrap and repair."""
78
+
79
+ config_version: int
80
+ bootstrap_version: int
81
+ runtime_mode: str
82
+ bootstrap_state: str
83
+ current_step: str | None
84
+ last_error: str | None
85
+ database: DatabaseState
86
+ managed: ManagedInstanceState
87
+ backups: BackupState
88
+ embeddings: EmbeddingRuntimeState
89
+
90
+ @property
91
+ def machine_instance_id(self) -> str:
92
+ """Return the active machine instance identifier."""
93
+
94
+ return self.managed.instance_id
95
+
96
+
97
+ def default_paths() -> dict[str, str]:
98
+ """Return default machine-owned storage paths."""
99
+
100
+ return {
101
+ "backups_root": str(get_machine_backups_dir()),
102
+ "models_root": str(get_machine_models_dir()),
103
+ }
104
+
105
+
106
+ def load_machine_config(path: Path | None = None) -> MachineConfig | None:
107
+ """Return the parsed machine config when it exists and is valid."""
108
+
109
+ target = path or get_machine_config_path()
110
+ try:
111
+ payload = tomllib.loads(target.read_text(encoding="utf-8"))
112
+ except FileNotFoundError:
113
+ return None
114
+ return _machine_config_from_payload(payload)
115
+
116
+
117
+ def try_load_machine_config(path: Path | None = None) -> tuple[MachineConfig | None, str | None]:
118
+ """Best-effort machine-config load that reports corruption text instead of raising."""
119
+
120
+ target = path or get_machine_config_path()
121
+ try:
122
+ return load_machine_config(target), None
123
+ except (tomllib.TOMLDecodeError, ValueError) as exc:
124
+ return None, str(exc)
125
+
126
+
127
+ def save_machine_config(config: MachineConfig, path: Path | None = None) -> Path:
128
+ """Persist one machine config with owner-only permissions."""
129
+
130
+ target = path or get_machine_config_path()
131
+ target.parent.mkdir(parents=True, exist_ok=True)
132
+ target.write_text(_render_machine_config(config), encoding="utf-8")
133
+ try:
134
+ os.chmod(target, 0o600)
135
+ except OSError:
136
+ pass
137
+ return target
138
+
139
+
140
+ def backup_corrupt_machine_config(*, path: Path | None = None) -> Path | None:
141
+ """Rename a corrupt config aside and return the backup path when present."""
142
+
143
+ target = path or get_machine_config_path()
144
+ if not target.exists():
145
+ return None
146
+ backup = target.with_name(f"config.corrupt.{_timestamp_slug()}.toml")
147
+ target.replace(backup)
148
+ return backup
149
+
150
+
151
+ def update_bootstrap_state(
152
+ config: MachineConfig,
153
+ *,
154
+ bootstrap_state: str,
155
+ current_step: str | None,
156
+ last_error: str | None,
157
+ ) -> MachineConfig:
158
+ """Return a copy with updated bootstrap status fields."""
159
+
160
+ return replace(
161
+ config,
162
+ bootstrap_state=bootstrap_state,
163
+ current_step=current_step,
164
+ last_error=last_error,
165
+ )
166
+
167
+
168
+ def build_recovery_stub(*, current_step: str | None, last_error: str | None) -> dict[str, Any]:
169
+ """Return a minimal recovery payload for config corruption cases."""
170
+
171
+ return {
172
+ "config_version": CONFIG_VERSION,
173
+ "bootstrap_version": BOOTSTRAP_VERSION,
174
+ "runtime_mode": RUNTIME_MODE_MANAGED_LOCAL,
175
+ "bootstrap_state": BOOTSTRAP_STATE_REPAIR_NEEDED,
176
+ "current_step": current_step,
177
+ "last_error": last_error,
178
+ "paths": default_paths(),
179
+ }
180
+
181
+
182
+ def save_recovery_stub(*, current_step: str | None, last_error: str | None, path: Path | None = None) -> Path:
183
+ """Persist a minimal repair-needed stub after config corruption."""
184
+
185
+ target = path or get_machine_config_path()
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ payload = build_recovery_stub(current_step=current_step, last_error=last_error)
188
+ target.write_text(_render_simple_payload(payload), encoding="utf-8")
189
+ try:
190
+ os.chmod(target, 0o600)
191
+ except OSError:
192
+ pass
193
+ return target
194
+
195
+
196
+ def _machine_config_from_payload(payload: dict[str, Any]) -> MachineConfig:
197
+ """Validate and coerce a machine-config payload."""
198
+
199
+ if not isinstance(payload, dict):
200
+ raise ValueError("Machine config must be a TOML table.")
201
+ database = payload.get("database")
202
+ managed = payload.get("managed")
203
+ backups = payload.get("backups")
204
+ embeddings = payload.get("embeddings")
205
+ if not isinstance(database, dict) or not isinstance(managed, dict):
206
+ raise ValueError("Machine config is missing required database or managed sections.")
207
+ if not isinstance(backups, dict) or not isinstance(embeddings, dict):
208
+ raise ValueError("Machine config is missing required backups or embeddings sections.")
209
+ return MachineConfig(
210
+ config_version=int(payload.get("config_version") or 0),
211
+ bootstrap_version=int(payload.get("bootstrap_version") or 0),
212
+ runtime_mode=str(payload.get("runtime_mode") or ""),
213
+ bootstrap_state=str(payload.get("bootstrap_state") or ""),
214
+ current_step=_optional_str(payload.get("current_step")),
215
+ last_error=_optional_str(payload.get("last_error")),
216
+ database=DatabaseState(
217
+ app_dsn=_required_str(database, "app_dsn"),
218
+ admin_dsn=_required_str(database, "admin_dsn"),
219
+ ),
220
+ managed=ManagedInstanceState(
221
+ instance_id=_required_str(managed, "instance_id"),
222
+ container_name=_required_str(managed, "container_name"),
223
+ image=_required_str(managed, "image"),
224
+ host=_required_str(managed, "host"),
225
+ port=int(managed.get("port") or 0),
226
+ db_name=_required_str(managed, "db_name"),
227
+ data_dir=_required_str(managed, "data_dir"),
228
+ admin_user=_required_str(managed, "admin_user"),
229
+ admin_password=_required_str(managed, "admin_password"),
230
+ app_user=_required_str(managed, "app_user"),
231
+ app_password=_required_str(managed, "app_password"),
232
+ ),
233
+ backups=BackupState(
234
+ root=_required_str(backups, "root"),
235
+ mirror_root=_optional_str(backups.get("mirror_root")),
236
+ ),
237
+ embeddings=EmbeddingRuntimeState(
238
+ provider=_required_str(embeddings, "provider"),
239
+ model=_required_str(embeddings, "model"),
240
+ model_revision=_optional_str(embeddings.get("model_revision")),
241
+ backend_version=_optional_str(embeddings.get("backend_version")),
242
+ cache_path=_required_str(embeddings, "cache_path"),
243
+ readiness_state=_required_str(embeddings, "readiness_state"),
244
+ last_error=_optional_str(embeddings.get("last_error")),
245
+ ),
246
+ )
247
+
248
+
249
+ def _required_str(payload: dict[str, Any], key: str) -> str:
250
+ """Return a required string field or raise."""
251
+
252
+ value = payload.get(key)
253
+ if not isinstance(value, str) or not value:
254
+ raise ValueError(f"Machine config field {key!r} must be a non-empty string.")
255
+ return value
256
+
257
+
258
+ def _optional_str(value: Any) -> str | None:
259
+ """Return an optional string value."""
260
+
261
+ if value is None:
262
+ return None
263
+ if not isinstance(value, str):
264
+ raise ValueError("Optional string fields must be strings when present.")
265
+ return value or None
266
+
267
+
268
+ def _render_machine_config(config: MachineConfig) -> str:
269
+ """Render one machine config to TOML."""
270
+
271
+ payload = {
272
+ "config_version": config.config_version,
273
+ "bootstrap_version": config.bootstrap_version,
274
+ "runtime_mode": config.runtime_mode,
275
+ "bootstrap_state": config.bootstrap_state,
276
+ "current_step": config.current_step,
277
+ "last_error": config.last_error,
278
+ "database": {
279
+ "app_dsn": config.database.app_dsn,
280
+ "admin_dsn": config.database.admin_dsn,
281
+ },
282
+ "managed": {
283
+ "instance_id": config.managed.instance_id,
284
+ "container_name": config.managed.container_name,
285
+ "image": config.managed.image,
286
+ "host": config.managed.host,
287
+ "port": config.managed.port,
288
+ "db_name": config.managed.db_name,
289
+ "data_dir": config.managed.data_dir,
290
+ "admin_user": config.managed.admin_user,
291
+ "admin_password": config.managed.admin_password,
292
+ "app_user": config.managed.app_user,
293
+ "app_password": config.managed.app_password,
294
+ },
295
+ "backups": {
296
+ "root": config.backups.root,
297
+ "mirror_root": config.backups.mirror_root,
298
+ },
299
+ "embeddings": {
300
+ "provider": config.embeddings.provider,
301
+ "model": config.embeddings.model,
302
+ "model_revision": config.embeddings.model_revision,
303
+ "backend_version": config.embeddings.backend_version,
304
+ "cache_path": config.embeddings.cache_path,
305
+ "readiness_state": config.embeddings.readiness_state,
306
+ "last_error": config.embeddings.last_error,
307
+ },
308
+ }
309
+ return _render_simple_payload(payload)
310
+
311
+
312
+ def _render_simple_payload(payload: dict[str, Any]) -> str:
313
+ """Render a limited nested mapping to TOML."""
314
+
315
+ root_lines: list[str] = []
316
+ section_lines: list[str] = []
317
+ for key, value in payload.items():
318
+ if isinstance(value, dict):
319
+ section_lines.extend(_render_table(key, value))
320
+ else:
321
+ root_lines.append(f"{key} = {_toml_value(value)}")
322
+ lines = [*root_lines]
323
+ if root_lines and section_lines:
324
+ lines.append("")
325
+ lines.extend(section_lines)
326
+ return "\n".join(lines) + "\n"
327
+
328
+
329
+ def _render_table(name: str, payload: dict[str, Any]) -> list[str]:
330
+ """Render one TOML table."""
331
+
332
+ lines = [f"[{name}]"]
333
+ for key, value in payload.items():
334
+ lines.append(f"{key} = {_toml_value(value)}")
335
+ lines.append("")
336
+ return lines
337
+
338
+
339
+ def _toml_value(value: Any) -> str:
340
+ """Render one limited TOML literal."""
341
+
342
+ if value is None:
343
+ return '""'
344
+ if isinstance(value, bool):
345
+ return "true" if value else "false"
346
+ if isinstance(value, int):
347
+ return str(value)
348
+ return json.dumps(str(value))
349
+
350
+
351
+ def _timestamp_slug() -> str:
352
+ """Return a filesystem-safe timestamp slug."""
353
+
354
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
@@ -0,0 +1,42 @@
1
+ """Admin helpers that reconcile safe runtime privileges for the app role."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse
6
+
7
+ import psycopg
8
+
9
+
10
+ def reconcile_app_role_privileges(*, admin_dsn: str, app_dsn: str) -> None:
11
+ """Grant only the DML/runtime privileges the app role needs after admin migrations."""
12
+
13
+ app_user = _username_from_dsn(app_dsn)
14
+ if not app_user:
15
+ raise RuntimeError("Could not determine the app-role username from SHELLBRAIN_DB_DSN.")
16
+
17
+ raw_admin_dsn = admin_dsn.replace("+psycopg", "")
18
+ with psycopg.connect(raw_admin_dsn, autocommit=True) as conn:
19
+ with conn.cursor() as cur:
20
+ cur.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC")
21
+ cur.execute(f'REVOKE CREATE ON SCHEMA public FROM "{app_user}"')
22
+ cur.execute(f'GRANT USAGE ON SCHEMA public TO "{app_user}"')
23
+ cur.execute(
24
+ f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "{app_user}"'
25
+ )
26
+ cur.execute(
27
+ f'GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO "{app_user}"'
28
+ )
29
+ cur.execute(
30
+ f'ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA public '
31
+ f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "{app_user}"'
32
+ )
33
+ cur.execute(
34
+ f'ALTER DEFAULT PRIVILEGES FOR ROLE CURRENT_USER IN SCHEMA public '
35
+ f'GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO "{app_user}"'
36
+ )
37
+
38
+
39
+ def _username_from_dsn(dsn: str) -> str:
40
+ """Extract the username from one DSN."""
41
+
42
+ return urlparse(dsn.replace("+psycopg", "")).username or ""