Skip to content

Commit f907b9e

Browse files
committed
feat: add guided init storage setup
1 parent c0e8c0c commit f907b9e

21 files changed

Lines changed: 1585 additions & 606 deletions

‎README.md‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,17 @@ It stores what happened, what worked, what failed, and what the human prefers
1111
curl -L shellbrain.ai/install | bash
1212
```
1313

14-
**One command on supported machines.** The installer provisions the local runtime, installs the Codex and Claude skills, wires the Claude SessionStart hook, and runs `shellbrain init` for you when the managed-local prerequisites are met. Repos register themselves on first use.
14+
**One command on supported machines.** The installer provisions the local runtime, installs the Codex and Claude skills, wires the Claude SessionStart hook, and runs `shellbrain init` for you. On first bootstrap, `shellbrain init` asks how it should store data. Repos register themselves on first use.
1515

16-
**Managed-local requirements**
16+
**Requirements**
1717

1818
- macOS or Linux
1919
- Python 3.11+ required
20-
- Docker installed and the daemon running
20+
- `shellbrain init` asks you to choose one storage mode on first bootstrap
21+
- managed local: Docker installed and the daemon running
22+
- external: PostgreSQL with pgvector
2123
- first init downloads a local embedding model
22-
- PostgreSQL + pgvector run inside the managed Docker runtime
2324
- Windows is not supported yet
24-
- external Postgres remains an advanced/operator-managed path for now
25-
26-
The repo `Dockerfile` is for packaging and development smoke coverage. It is not the end-user runtime path.
2725

2826
---
2927

‎app/periphery/admin/doctor.py‎

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@
1111
import psycopg
1212

1313
from app.boot.home import get_shellbrain_home
14+
from app.periphery.admin import external_runtime
1415
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
16+
from app.periphery.admin.instance_guard import fetch_instance_metadata, fingerprint_summary, inspect_role_safety
17+
from app.periphery.admin.machine_state import (
18+
MachineConfig,
19+
RUNTIME_MODE_EXTERNAL_POSTGRES,
20+
RUNTIME_MODE_MANAGED_LOCAL,
21+
try_load_machine_config,
22+
)
1723
from app.periphery.admin.repo_state import IDENTITY_STRENGTH_WEAK_LOCAL, load_repo_registration_for_target, resolve_git_root
1824
from app.periphery.onboarding.host_assets import inspect_host_assets
1925

@@ -48,6 +54,7 @@ def build_doctor_report(
4854
disk = shutil.disk_usage(home_root if home_root.exists() else home_root.parent)
4955
repo_report = _build_repo_report(repo_root=repo_root)
5056
host_integrations = inspect_host_assets()
57+
runtime_warnings = _runtime_warnings(machine_config)
5158

5259
report: dict[str, Any] = {
5360
"checked_at": checked_at.isoformat(),
@@ -61,6 +68,7 @@ def build_doctor_report(
6168
"config_version": None if machine_config is None else machine_config.config_version,
6269
"bootstrap_version": None if machine_config is None else machine_config.bootstrap_version,
6370
"machine_instance": _machine_instance_report(machine_config),
71+
"runtime_warnings": runtime_warnings,
6472
"effective_config": _effective_config_summary(machine_config=machine_config, app_dsn=app_dsn, admin_dsn=admin_dsn),
6573
"app_dsn_configured": bool(app_dsn),
6674
"admin_dsn_configured": bool(admin_dsn),
@@ -127,18 +135,13 @@ def _config_status(*, machine_config: MachineConfig | None, machine_error: str |
127135

128136

129137
def _machine_instance_report(machine_config: MachineConfig | None) -> dict[str, Any] | None:
130-
"""Return managed-instance details when machine config exists."""
138+
"""Return runtime details when machine config exists."""
131139

132140
if machine_config is None:
133141
return None
134-
return {
142+
report: dict[str, Any] = {
135143
"instance_id": machine_config.machine_instance_id,
136-
"container_name": machine_config.managed.container_name,
137-
"image": machine_config.managed.image,
138-
"host": machine_config.managed.host,
139-
"port": machine_config.managed.port,
140-
"db_name": machine_config.managed.db_name,
141-
"data_dir": machine_config.managed.data_dir,
144+
"runtime_mode": machine_config.runtime_mode,
142145
"backup_root": machine_config.backups.root,
143146
"embeddings": {
144147
"provider": machine_config.embeddings.provider,
@@ -150,6 +153,31 @@ def _machine_instance_report(machine_config: MachineConfig | None) -> dict[str,
150153
"last_error": machine_config.embeddings.last_error,
151154
},
152155
}
156+
if machine_config.runtime_mode == RUNTIME_MODE_MANAGED_LOCAL and machine_config.managed is not None:
157+
report.update(
158+
{
159+
"container_name": machine_config.managed.container_name,
160+
"image": machine_config.managed.image,
161+
"host": machine_config.managed.host,
162+
"port": machine_config.managed.port,
163+
"db_name": machine_config.managed.db_name,
164+
"data_dir": machine_config.managed.data_dir,
165+
}
166+
)
167+
return report
168+
if machine_config.runtime_mode == RUNTIME_MODE_EXTERNAL_POSTGRES:
169+
report["database"] = fingerprint_summary(machine_config.database.admin_dsn)
170+
return report
171+
172+
173+
def _runtime_warnings(machine_config: MachineConfig | None) -> list[str]:
174+
"""Return runtime-specific warnings for the active machine config."""
175+
176+
if machine_config is None:
177+
return []
178+
if machine_config.runtime_mode == RUNTIME_MODE_EXTERNAL_POSTGRES:
179+
return external_runtime.inspect_runtime(admin_dsn=machine_config.database.admin_dsn)
180+
return []
153181

154182

155183
def _effective_config_summary(
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""External PostgreSQL runtime helpers for Shellbrain init."""
2+
3+
from __future__ import annotations
4+
5+
import importlib.metadata
6+
import secrets
7+
8+
import psycopg
9+
from psycopg import sql
10+
from sqlalchemy.engine import make_url
11+
12+
from app.boot.config import get_config_provider
13+
from app.boot.home import get_machine_backups_dir, get_machine_models_dir
14+
from app.periphery.admin.init_errors import InitConflictError
15+
from app.periphery.admin.instance_guard import dsn_fingerprint, ensure_instance_metadata
16+
from app.periphery.admin.machine_state import (
17+
BOOTSTRAP_STATE_PROVISIONING,
18+
BOOTSTRAP_VERSION,
19+
CONFIG_VERSION,
20+
BackupState,
21+
DatabaseState,
22+
EmbeddingRuntimeState,
23+
MachineConfig,
24+
RUNTIME_MODE_EXTERNAL_POSTGRES,
25+
)
26+
from app.periphery.admin.privileges import reconcile_app_role_privileges
27+
28+
29+
DEFAULT_EXTERNAL_APP_USER = "shellbrain_app"
30+
31+
32+
def build_fresh_machine_config(*, admin_dsn: str) -> MachineConfig:
33+
"""Construct one fresh machine config for external PostgreSQL mode."""
34+
35+
runtime = get_config_provider().get_runtime()
36+
embeddings = runtime.get("embeddings")
37+
if not isinstance(embeddings, dict):
38+
raise RuntimeError("runtime.embeddings must be configured")
39+
app_dsn = _provision_external_app_role(admin_dsn=admin_dsn)
40+
instance_id = dsn_fingerprint(admin_dsn)
41+
backend_version = _sentence_transformers_version()
42+
return MachineConfig(
43+
config_version=CONFIG_VERSION,
44+
bootstrap_version=BOOTSTRAP_VERSION,
45+
instance_id=instance_id,
46+
runtime_mode=RUNTIME_MODE_EXTERNAL_POSTGRES,
47+
bootstrap_state=BOOTSTRAP_STATE_PROVISIONING,
48+
current_step="bootstrap",
49+
last_error=None,
50+
database=DatabaseState(app_dsn=app_dsn, admin_dsn=admin_dsn),
51+
managed=None,
52+
backups=BackupState(root=str(get_machine_backups_dir()), mirror_root=None),
53+
embeddings=EmbeddingRuntimeState(
54+
provider=str(embeddings.get("provider") or "sentence_transformers"),
55+
model=str(embeddings.get("model") or "all-MiniLM-L6-v2"),
56+
model_revision=None,
57+
backend_version=backend_version,
58+
cache_path=str(get_machine_models_dir()),
59+
readiness_state="pending",
60+
last_error=None,
61+
),
62+
)
63+
64+
65+
def reconcile_database(config: MachineConfig) -> tuple[bool, MachineConfig]:
66+
"""Validate one external PostgreSQL database and reconcile the Shellbrain app role."""
67+
68+
if config.runtime_mode != RUNTIME_MODE_EXTERNAL_POSTGRES:
69+
raise InitConflictError("External runtime reconciliation requires external_postgres mode.")
70+
_validate_external_admin_dsn(config.database.admin_dsn)
71+
app_dsn = _provision_external_app_role(
72+
admin_dsn=config.database.admin_dsn,
73+
existing_app_dsn=config.database.app_dsn,
74+
)
75+
ensure_instance_metadata(
76+
config.database.admin_dsn,
77+
instance_mode="live",
78+
created_by="app.init",
79+
notes="External PostgreSQL Shellbrain instance",
80+
)
81+
if app_dsn == config.database.app_dsn:
82+
return False, config
83+
updated = MachineConfig(
84+
config_version=config.config_version,
85+
bootstrap_version=config.bootstrap_version,
86+
instance_id=config.instance_id,
87+
runtime_mode=config.runtime_mode,
88+
bootstrap_state=config.bootstrap_state,
89+
current_step=config.current_step,
90+
last_error=config.last_error,
91+
database=DatabaseState(app_dsn=app_dsn, admin_dsn=config.database.admin_dsn),
92+
managed=config.managed,
93+
backups=config.backups,
94+
embeddings=config.embeddings,
95+
)
96+
return True, updated
97+
98+
99+
def inspect_runtime(*, admin_dsn: str) -> list[str]:
100+
"""Return best-effort warnings for one external PostgreSQL runtime."""
101+
102+
warnings: list[str] = []
103+
try:
104+
_validate_external_admin_dsn(admin_dsn)
105+
except InitConflictError as exc:
106+
warnings.append(str(exc))
107+
return warnings
108+
109+
110+
def _validate_external_admin_dsn(admin_dsn: str) -> None:
111+
"""Verify the external admin DSN reaches PostgreSQL with pgvector available."""
112+
113+
raw_admin_dsn = admin_dsn.replace("+psycopg", "")
114+
try:
115+
with psycopg.connect(raw_admin_dsn, autocommit=True) as conn:
116+
with conn.cursor() as cur:
117+
cur.execute("SELECT version()")
118+
version_row = cur.fetchone()
119+
if version_row is None or "PostgreSQL" not in str(version_row[0]):
120+
raise InitConflictError("External database must be PostgreSQL.")
121+
try:
122+
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
123+
except psycopg.Error as exc:
124+
raise InitConflictError(
125+
"External PostgreSQL database must have pgvector available to install or already installed."
126+
) from exc
127+
cur.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'")
128+
if cur.fetchone() is None:
129+
raise InitConflictError(
130+
"External PostgreSQL database must have the pgvector extension installed."
131+
)
132+
except InitConflictError:
133+
raise
134+
except psycopg.Error as exc:
135+
raise InitConflictError(f"Could not connect to the external PostgreSQL admin DSN: {exc}") from exc
136+
137+
138+
def _provision_external_app_role(*, admin_dsn: str, existing_app_dsn: str | None = None) -> str:
139+
"""Create or repair the dedicated Shellbrain app role for one external database."""
140+
141+
app_user = DEFAULT_EXTERNAL_APP_USER
142+
app_password = None
143+
if existing_app_dsn:
144+
app_url = make_url(existing_app_dsn)
145+
app_user = app_url.username or DEFAULT_EXTERNAL_APP_USER
146+
app_password = app_url.password
147+
if not app_password:
148+
app_password = secrets.token_hex(16)
149+
app_dsn = _app_dsn_from_admin_dsn(admin_dsn=admin_dsn, app_user=app_user, app_password=app_password)
150+
raw_admin_dsn = admin_dsn.replace("+psycopg", "")
151+
database_name = make_url(admin_dsn).database
152+
if not database_name:
153+
raise InitConflictError("External PostgreSQL admin DSN must include a database name.")
154+
try:
155+
with psycopg.connect(raw_admin_dsn, autocommit=True) as conn:
156+
with conn.cursor() as cur:
157+
cur.execute("SELECT 1 FROM pg_roles WHERE rolname = %s", (app_user,))
158+
if cur.fetchone() is None:
159+
cur.execute(
160+
sql.SQL("CREATE ROLE {} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD {}").format(
161+
sql.Identifier(app_user),
162+
sql.Literal(app_password),
163+
),
164+
)
165+
else:
166+
cur.execute(
167+
sql.SQL("ALTER ROLE {} WITH PASSWORD {}").format(
168+
sql.Identifier(app_user),
169+
sql.Literal(app_password),
170+
),
171+
)
172+
cur.execute(
173+
sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format(
174+
sql.Identifier(database_name),
175+
sql.Identifier(app_user),
176+
)
177+
)
178+
except psycopg.Error as exc:
179+
raise InitConflictError(f"Could not provision the external Shellbrain app role: {exc}") from exc
180+
reconcile_app_role_privileges(admin_dsn=admin_dsn, app_dsn=app_dsn)
181+
return app_dsn
182+
183+
184+
def _app_dsn_from_admin_dsn(*, admin_dsn: str, app_user: str, app_password: str) -> str:
185+
"""Build one Shellbrain app DSN from the external admin DSN shape."""
186+
187+
url = make_url(admin_dsn)
188+
return str(url.set(username=app_user, password=app_password))
189+
190+
191+
def _sentence_transformers_version() -> str | None:
192+
"""Return the installed sentence-transformers version when present."""
193+
194+
try:
195+
return importlib.metadata.version("sentence-transformers")
196+
except importlib.metadata.PackageNotFoundError:
197+
return None

0 commit comments

Comments
 (0)