lovarch-cli 0.2.1__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 (122) hide show
  1. lovarch_cli/__init__.py +16 -0
  2. lovarch_cli/__main__.py +10 -0
  3. lovarch_cli/ai/__init__.py +21 -0
  4. lovarch_cli/ai/gateway.py +240 -0
  5. lovarch_cli/api.py +111 -0
  6. lovarch_cli/auth/__init__.py +32 -0
  7. lovarch_cli/auth/keyring_store.py +214 -0
  8. lovarch_cli/auth/local_server.py +165 -0
  9. lovarch_cli/auth/pkce.py +57 -0
  10. lovarch_cli/auth/session.py +189 -0
  11. lovarch_cli/cli.py +262 -0
  12. lovarch_cli/clients/__init__.py +33 -0
  13. lovarch_cli/clients/factory.py +54 -0
  14. lovarch_cli/clients/local_client.py +432 -0
  15. lovarch_cli/clients/lovarch_storage.py +174 -0
  16. lovarch_cli/clients/lovarch_supabase.py +295 -0
  17. lovarch_cli/clients/persistence.py +166 -0
  18. lovarch_cli/clients/storage.py +66 -0
  19. lovarch_cli/commands/__init__.py +10 -0
  20. lovarch_cli/commands/account.py +172 -0
  21. lovarch_cli/commands/audit.py +394 -0
  22. lovarch_cli/commands/config_cmd.py +80 -0
  23. lovarch_cli/commands/consolidate.py +217 -0
  24. lovarch_cli/commands/context_cmd.py +73 -0
  25. lovarch_cli/commands/dev.py +287 -0
  26. lovarch_cli/commands/do_cmd.py +120 -0
  27. lovarch_cli/commands/init.py +218 -0
  28. lovarch_cli/commands/jobs_cmd.py +95 -0
  29. lovarch_cli/commands/login.py +202 -0
  30. lovarch_cli/commands/mcp_cmd.py +26 -0
  31. lovarch_cli/commands/run.py +375 -0
  32. lovarch_cli/commands/signup.py +185 -0
  33. lovarch_cli/commands/status.py +243 -0
  34. lovarch_cli/commands/upgrade.py +108 -0
  35. lovarch_cli/commands/verifica_cmd.py +174 -0
  36. lovarch_cli/config.py +101 -0
  37. lovarch_cli/config_store.py +111 -0
  38. lovarch_cli/credits/__init__.py +35 -0
  39. lovarch_cli/credits/base.py +84 -0
  40. lovarch_cli/credits/factory.py +36 -0
  41. lovarch_cli/credits/local.py +34 -0
  42. lovarch_cli/credits/lovarch.py +56 -0
  43. lovarch_cli/i18n/__init__.py +27 -0
  44. lovarch_cli/i18n/loader.py +121 -0
  45. lovarch_cli/i18n/translations/en.json +168 -0
  46. lovarch_cli/i18n/translations/es.json +168 -0
  47. lovarch_cli/i18n/translations/it.json +168 -0
  48. lovarch_cli/i18n/translations/pt.json +168 -0
  49. lovarch_cli/mcp/__init__.py +9 -0
  50. lovarch_cli/mcp/server.py +199 -0
  51. lovarch_cli/mcp/tools.py +372 -0
  52. lovarch_cli/sample_downloader.py +255 -0
  53. lovarch_cli/squad/README.md +206 -0
  54. lovarch_cli/squad/agents/auditor-input.md +353 -0
  55. lovarch_cli/squad/agents/bim-engineer.md +404 -0
  56. lovarch_cli/squad/agents/briefing-architect.md +249 -0
  57. lovarch_cli/squad/agents/cad-engineer.md +278 -0
  58. lovarch_cli/squad/agents/capitolato-writer.md +256 -0
  59. lovarch_cli/squad/agents/computo-engineer.md +258 -0
  60. lovarch_cli/squad/agents/concept-designer.md +399 -0
  61. lovarch_cli/squad/agents/contratto-architect.md +243 -0
  62. lovarch_cli/squad/agents/deliverable-builder.md +253 -0
  63. lovarch_cli/squad/agents/energy-prelim.md +388 -0
  64. lovarch_cli/squad/agents/pratiche-it.md +251 -0
  65. lovarch_cli/squad/agents/progetto-chief.md +768 -0
  66. lovarch_cli/squad/agents/quality-dati.md +409 -0
  67. lovarch_cli/squad/agents/quality-misure.md +418 -0
  68. lovarch_cli/squad/agents/quality-normativa.md +417 -0
  69. lovarch_cli/squad/agents/quality-output.md +436 -0
  70. lovarch_cli/squad/agents/regolatorio-it.md +278 -0
  71. lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
  72. lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
  73. lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
  74. lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
  75. lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
  76. lovarch_cli/squad/config.yaml +408 -0
  77. lovarch_cli/squad/data/CHANGELOG.md +272 -0
  78. lovarch_cli/squad/data/agents-prd.md +428 -0
  79. lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
  80. lovarch_cli/squad/data/handoff-card-template.md +231 -0
  81. lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
  82. lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
  83. lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
  84. lovarch_cli/squad/scripts/api_clients.py +206 -0
  85. lovarch_cli/squad/scripts/architect_profile.py +276 -0
  86. lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
  87. lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
  88. lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
  89. lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
  90. lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
  91. lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
  92. lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
  93. lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
  94. lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
  95. lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
  96. lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
  97. lovarch_cli/squad/scripts/validate-squad.py +383 -0
  98. lovarch_cli/squad/tasks/audit-input.md +146 -0
  99. lovarch_cli/squad/tasks/compute-metric.md +105 -0
  100. lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
  101. lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
  102. lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
  103. lovarch_cli/squad/tasks/write-capitolato.md +100 -0
  104. lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
  105. lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
  106. lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
  107. lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
  108. lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
  109. lovarch_cli/squad_loader.py +114 -0
  110. lovarch_cli/verify/__init__.py +15 -0
  111. lovarch_cli/verify/contratto.py +110 -0
  112. lovarch_cli/verify/dossier.py +97 -0
  113. lovarch_cli/verify/misure.py +83 -0
  114. lovarch_cli/verify/normativa.py +178 -0
  115. lovarch_cli/version.py +13 -0
  116. lovarch_cli/workflows/__init__.py +9 -0
  117. lovarch_cli/workflows/platform.py +212 -0
  118. lovarch_cli-0.2.1.dist-info/METADATA +232 -0
  119. lovarch_cli-0.2.1.dist-info/RECORD +122 -0
  120. lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
  121. lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
  122. lovarch_cli-0.2.1.dist-info/licenses/LICENSE +38 -0
@@ -0,0 +1,33 @@
1
+ """Backend clients — abstract interfaces + concrete implementations.
2
+
3
+ Two backend modes:
4
+ - Free: LocalSqliteClient + LocalFilesystemStorage (no Lovarch deps)
5
+ - Premium: LovarchSupabaseClient + LovarchS3Storage (Epic 3 — Lovarch backend)
6
+
7
+ Use `get_clients(mode)` from factory.py to obtain the correct pair for the
8
+ current authentication state.
9
+ """
10
+ from lovarch_cli.clients.factory import get_clients
11
+ from lovarch_cli.clients.persistence import (
12
+ DataPersistenceClient,
13
+ Execution,
14
+ ExecutionMode,
15
+ QaCheck,
16
+ QaVerdict,
17
+ Step,
18
+ StepStatus,
19
+ )
20
+ from lovarch_cli.clients.storage import StorageClient, StoredArtifact
21
+
22
+ __all__ = [
23
+ "DataPersistenceClient",
24
+ "Execution",
25
+ "ExecutionMode",
26
+ "QaCheck",
27
+ "QaVerdict",
28
+ "StepStatus",
29
+ "StorageClient",
30
+ "Step",
31
+ "StoredArtifact",
32
+ "get_clients",
33
+ ]
@@ -0,0 +1,54 @@
1
+ """Client factory — returns the correct (DataPersistenceClient, StorageClient)
2
+ pair based on authentication mode.
3
+
4
+ Free mode → LocalSqliteClient + LocalFilesystemStorage
5
+ Premium mode → LovarchSupabaseClient + LovarchStorage (uses keyring tokens)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from lovarch_cli.clients.local_client import LocalFilesystemStorage, LocalSqliteClient
12
+ from lovarch_cli.clients.persistence import DataPersistenceClient, ExecutionMode
13
+ from lovarch_cli.clients.storage import StorageClient
14
+
15
+
16
+ def get_clients(
17
+ mode: ExecutionMode,
18
+ lovarch_home: Path | None = None,
19
+ ) -> tuple[DataPersistenceClient, StorageClient]:
20
+ """Return (persistence_client, storage_client) for the given mode.
21
+
22
+ Args:
23
+ mode: free or premium
24
+ lovarch_home: override default ~/.lovarch/ (mainly for tests)
25
+
26
+ Raises:
27
+ RuntimeError: premium requested but no session in keyring (user must
28
+ run `arch login --premium` first).
29
+ """
30
+ if mode == ExecutionMode.FREE:
31
+ persistence = LocalSqliteClient(lovarch_home=lovarch_home)
32
+ # Reuse same SQLite handle for artifact metadata
33
+ storage = LocalFilesystemStorage(
34
+ lovarch_home=lovarch_home, sqlite_client=persistence
35
+ )
36
+ return persistence, storage
37
+
38
+ if mode == ExecutionMode.PREMIUM:
39
+ # Lazy imports — avoid loading httpx + Supabase modules in free flows
40
+ from lovarch_cli.auth.session import LovarchSession
41
+ from lovarch_cli.clients.lovarch_storage import LovarchStorage
42
+ from lovarch_cli.clients.lovarch_supabase import LovarchSupabaseClient
43
+
44
+ session = LovarchSession.load()
45
+ if session is None:
46
+ msg = (
47
+ "Premium mode requires authentication. Run "
48
+ "'lovarch login --premium' first."
49
+ )
50
+ raise RuntimeError(msg)
51
+ return LovarchSupabaseClient(session), LovarchStorage(session)
52
+
53
+ msg = f"Unknown execution mode: {mode}"
54
+ raise ValueError(msg)
@@ -0,0 +1,432 @@
1
+ """LocalSqliteClient + LocalFilesystemStorage — free-mode backend.
2
+
3
+ Stores execution tracking in ~/.lovarch/local.db (SQLite) and artifacts in
4
+ ~/.lovarch/projects/{execution_id}/{filename}. Schema mirrors the relevant
5
+ subset of Lovarch's pm_squad_executions/steps/qa_checks tables.
6
+
7
+ Async API: stdlib sqlite3 is sync, but we wrap calls in asyncio.to_thread()
8
+ so the public interface matches the abstract contract. This avoids adding
9
+ aiosqlite as a dep for what is mostly low-volume tracking.
10
+
11
+ Threading note: each method opens its own connection (short-lived) — SQLite
12
+ file locking handles concurrent writers fine for our access pattern (one
13
+ pipeline_runner at a time, ~50 writes per execution).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import mimetypes
20
+ import shutil
21
+ import sqlite3
22
+ import uuid
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from lovarch_cli.clients.persistence import (
28
+ DataPersistenceClient,
29
+ Execution,
30
+ ExecutionMode,
31
+ QaCheck,
32
+ QaVerdict,
33
+ Step,
34
+ StepStatus,
35
+ )
36
+ from lovarch_cli.clients.storage import StorageClient, StoredArtifact
37
+
38
+ DEFAULT_LOVARCH_HOME = Path.home() / ".lovarch"
39
+
40
+ SCHEMA_SQL = """
41
+ CREATE TABLE IF NOT EXISTS executions (
42
+ id TEXT PRIMARY KEY,
43
+ project_id TEXT NOT NULL,
44
+ workflow TEXT NOT NULL,
45
+ mode TEXT NOT NULL CHECK (mode IN ('free','premium')),
46
+ status TEXT NOT NULL DEFAULT 'pending',
47
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
48
+ completed_at TEXT,
49
+ metadata TEXT
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS steps (
53
+ id TEXT PRIMARY KEY,
54
+ execution_id TEXT NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
55
+ agent TEXT NOT NULL,
56
+ task TEXT,
57
+ status TEXT NOT NULL,
58
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
59
+ completed_at TEXT,
60
+ artifact_url TEXT,
61
+ error TEXT,
62
+ metadata TEXT
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS qa_checks (
66
+ id TEXT PRIMARY KEY,
67
+ execution_id TEXT NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
68
+ qa_agent TEXT NOT NULL,
69
+ target_step_id TEXT REFERENCES steps(id),
70
+ verdict TEXT NOT NULL CHECK (verdict IN ('PASS','CONCERNS','FAIL','WAIVED')),
71
+ findings TEXT,
72
+ checked_at TEXT NOT NULL DEFAULT (datetime('now'))
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS artifacts (
76
+ id TEXT PRIMARY KEY,
77
+ execution_id TEXT NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
78
+ filename TEXT NOT NULL,
79
+ path TEXT NOT NULL,
80
+ mime_type TEXT,
81
+ size_bytes INTEGER NOT NULL,
82
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
83
+ );
84
+
85
+ CREATE INDEX IF NOT EXISTS idx_steps_execution ON steps(execution_id);
86
+ CREATE INDEX IF NOT EXISTS idx_qa_execution ON qa_checks(execution_id);
87
+ CREATE INDEX IF NOT EXISTS idx_artifacts_execution ON artifacts(execution_id);
88
+ """
89
+
90
+
91
+ def _now_iso() -> str:
92
+ return datetime.utcnow().isoformat(timespec="seconds")
93
+
94
+
95
+ def _connect(db_path: Path) -> sqlite3.Connection:
96
+ db_path.parent.mkdir(parents=True, exist_ok=True)
97
+ conn = sqlite3.connect(db_path, isolation_level=None) # autocommit
98
+ conn.row_factory = sqlite3.Row
99
+ conn.execute("PRAGMA foreign_keys = ON")
100
+ conn.execute("PRAGMA journal_mode = WAL")
101
+ return conn
102
+
103
+
104
+ def _ensure_schema(db_path: Path) -> None:
105
+ conn = _connect(db_path)
106
+ try:
107
+ conn.executescript(SCHEMA_SQL)
108
+ finally:
109
+ conn.close()
110
+
111
+
112
+ def _parse_dt(value: str | None) -> datetime | None:
113
+ if not value:
114
+ return None
115
+ return datetime.fromisoformat(value)
116
+
117
+
118
+ def _parse_metadata(blob: str | None) -> dict[str, Any]:
119
+ if not blob:
120
+ return {}
121
+ try:
122
+ return json.loads(blob)
123
+ except json.JSONDecodeError:
124
+ return {}
125
+
126
+
127
+ # ════════════════════════════════════════════════════════════════════════════
128
+ # DataPersistenceClient — SQLite implementation
129
+ # ════════════════════════════════════════════════════════════════════════════
130
+
131
+
132
+ class LocalSqliteClient(DataPersistenceClient):
133
+ """SQLite-backed execution/steps/QA tracker (free mode)."""
134
+
135
+ def __init__(self, lovarch_home: Path | None = None) -> None:
136
+ self.home = lovarch_home or DEFAULT_LOVARCH_HOME
137
+ self.db_path = self.home / "local.db"
138
+ _ensure_schema(self.db_path)
139
+
140
+ def _execute(self, sql: str, params: tuple = ()) -> None:
141
+ conn = _connect(self.db_path)
142
+ try:
143
+ conn.execute(sql, params)
144
+ finally:
145
+ conn.close()
146
+
147
+ def _query(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
148
+ conn = _connect(self.db_path)
149
+ try:
150
+ return list(conn.execute(sql, params))
151
+ finally:
152
+ conn.close()
153
+
154
+ async def create_execution(
155
+ self,
156
+ project_id: str,
157
+ workflow: str,
158
+ mode: ExecutionMode,
159
+ metadata: dict[str, Any] | None = None,
160
+ ) -> str:
161
+ execution_id = str(uuid.uuid4())
162
+ await asyncio.to_thread(
163
+ self._execute,
164
+ "INSERT INTO executions(id, project_id, workflow, mode, status, metadata) "
165
+ "VALUES (?, ?, ?, ?, 'pending', ?)",
166
+ (execution_id, project_id, workflow, mode.value, json.dumps(metadata or {})),
167
+ )
168
+ return execution_id
169
+
170
+ async def update_execution(
171
+ self,
172
+ execution_id: str,
173
+ status: str,
174
+ completed_at: datetime | None = None,
175
+ metadata: dict[str, Any] | None = None,
176
+ ) -> None:
177
+ sets = ["status = ?"]
178
+ params: list[Any] = [status]
179
+ if completed_at is not None:
180
+ sets.append("completed_at = ?")
181
+ params.append(completed_at.isoformat(timespec="seconds"))
182
+ if metadata is not None:
183
+ sets.append("metadata = ?")
184
+ params.append(json.dumps(metadata))
185
+ params.append(execution_id)
186
+ await asyncio.to_thread(
187
+ self._execute,
188
+ f"UPDATE executions SET {', '.join(sets)} WHERE id = ?",
189
+ tuple(params),
190
+ )
191
+
192
+ async def insert_step(
193
+ self,
194
+ execution_id: str,
195
+ agent: str,
196
+ task: str | None,
197
+ status: StepStatus = StepStatus.TRIAGED,
198
+ ) -> str:
199
+ step_id = str(uuid.uuid4())
200
+ await asyncio.to_thread(
201
+ self._execute,
202
+ "INSERT INTO steps(id, execution_id, agent, task, status) "
203
+ "VALUES (?, ?, ?, ?, ?)",
204
+ (step_id, execution_id, agent, task, status.value),
205
+ )
206
+ return step_id
207
+
208
+ async def update_step(
209
+ self,
210
+ step_id: str,
211
+ status: StepStatus,
212
+ artifact_url: str | None = None,
213
+ error: str | None = None,
214
+ metadata: dict[str, Any] | None = None,
215
+ ) -> None:
216
+ sets = ["status = ?"]
217
+ params: list[Any] = [status.value]
218
+ if status in {StepStatus.DONE, StepStatus.QA_PASS, StepStatus.VALIDATED}:
219
+ sets.append("completed_at = ?")
220
+ params.append(_now_iso())
221
+ if artifact_url is not None:
222
+ sets.append("artifact_url = ?")
223
+ params.append(artifact_url)
224
+ if error is not None:
225
+ sets.append("error = ?")
226
+ params.append(error)
227
+ if metadata is not None:
228
+ sets.append("metadata = ?")
229
+ params.append(json.dumps(metadata))
230
+ params.append(step_id)
231
+ await asyncio.to_thread(
232
+ self._execute,
233
+ f"UPDATE steps SET {', '.join(sets)} WHERE id = ?",
234
+ tuple(params),
235
+ )
236
+
237
+ async def insert_qa_check(
238
+ self,
239
+ execution_id: str,
240
+ qa_agent: str,
241
+ target_step_id: str | None,
242
+ verdict: QaVerdict,
243
+ findings: str | None = None,
244
+ ) -> str:
245
+ qa_id = str(uuid.uuid4())
246
+ await asyncio.to_thread(
247
+ self._execute,
248
+ "INSERT INTO qa_checks(id, execution_id, qa_agent, target_step_id, "
249
+ "verdict, findings) VALUES (?, ?, ?, ?, ?, ?)",
250
+ (qa_id, execution_id, qa_agent, target_step_id, verdict.value, findings),
251
+ )
252
+ return qa_id
253
+
254
+ async def get_execution(self, execution_id: str) -> Execution | None:
255
+ rows = await asyncio.to_thread(
256
+ self._query,
257
+ "SELECT * FROM executions WHERE id = ?",
258
+ (execution_id,),
259
+ )
260
+ if not rows:
261
+ return None
262
+ r = rows[0]
263
+ return Execution(
264
+ id=r["id"],
265
+ project_id=r["project_id"],
266
+ workflow=r["workflow"],
267
+ mode=ExecutionMode(r["mode"]),
268
+ status=r["status"],
269
+ started_at=_parse_dt(r["started_at"]) or datetime.utcnow(),
270
+ completed_at=_parse_dt(r["completed_at"]),
271
+ metadata=_parse_metadata(r["metadata"]),
272
+ )
273
+
274
+ async def list_steps(self, execution_id: str) -> list[Step]:
275
+ rows = await asyncio.to_thread(
276
+ self._query,
277
+ "SELECT * FROM steps WHERE execution_id = ? ORDER BY started_at ASC",
278
+ (execution_id,),
279
+ )
280
+ return [
281
+ Step(
282
+ id=r["id"],
283
+ execution_id=r["execution_id"],
284
+ agent=r["agent"],
285
+ task=r["task"],
286
+ status=StepStatus(r["status"]),
287
+ started_at=_parse_dt(r["started_at"]) or datetime.utcnow(),
288
+ completed_at=_parse_dt(r["completed_at"]),
289
+ artifact_url=r["artifact_url"],
290
+ error=r["error"],
291
+ metadata=_parse_metadata(r["metadata"]),
292
+ )
293
+ for r in rows
294
+ ]
295
+
296
+ async def list_qa_checks(self, execution_id: str) -> list[QaCheck]:
297
+ rows = await asyncio.to_thread(
298
+ self._query,
299
+ "SELECT * FROM qa_checks WHERE execution_id = ? ORDER BY checked_at ASC",
300
+ (execution_id,),
301
+ )
302
+ return [
303
+ QaCheck(
304
+ id=r["id"],
305
+ execution_id=r["execution_id"],
306
+ qa_agent=r["qa_agent"],
307
+ target_step_id=r["target_step_id"],
308
+ verdict=QaVerdict(r["verdict"]),
309
+ findings=r["findings"],
310
+ checked_at=_parse_dt(r["checked_at"]) or datetime.utcnow(),
311
+ )
312
+ for r in rows
313
+ ]
314
+
315
+
316
+ # ════════════════════════════════════════════════════════════════════════════
317
+ # StorageClient — Filesystem implementation
318
+ # ════════════════════════════════════════════════════════════════════════════
319
+
320
+
321
+ class LocalFilesystemStorage(StorageClient):
322
+ """Filesystem-backed artifact storage (free mode).
323
+
324
+ Layout: {lovarch_home}/projects/{execution_id}/{filename}
325
+ Metadata is co-tracked in the SQLite `artifacts` table for fast listing.
326
+ """
327
+
328
+ def __init__(
329
+ self,
330
+ lovarch_home: Path | None = None,
331
+ sqlite_client: LocalSqliteClient | None = None,
332
+ ) -> None:
333
+ self.home = lovarch_home or DEFAULT_LOVARCH_HOME
334
+ self.projects_root = self.home / "projects"
335
+ self.projects_root.mkdir(parents=True, exist_ok=True)
336
+ # Reuse SQLite for artifact metadata
337
+ self._db = sqlite_client or LocalSqliteClient(self.home)
338
+
339
+ def _project_dir(self, execution_id: str) -> Path:
340
+ d = self.projects_root / execution_id
341
+ d.mkdir(parents=True, exist_ok=True)
342
+ return d
343
+
344
+ async def save_artifact(
345
+ self,
346
+ execution_id: str,
347
+ filename: str,
348
+ data: bytes | Path,
349
+ mime_type: str | None = None,
350
+ ) -> StoredArtifact:
351
+ target_dir = self._project_dir(execution_id)
352
+ target = target_dir / filename
353
+ if isinstance(data, Path):
354
+ await asyncio.to_thread(shutil.copy2, data, target)
355
+ else:
356
+ await asyncio.to_thread(target.write_bytes, data)
357
+
358
+ size = target.stat().st_size
359
+ mime = mime_type or mimetypes.guess_type(filename)[0]
360
+ artifact_id = str(uuid.uuid4())
361
+
362
+ await asyncio.to_thread(
363
+ self._db._execute,
364
+ "INSERT INTO artifacts(id, execution_id, filename, path, mime_type, "
365
+ "size_bytes) VALUES (?, ?, ?, ?, ?, ?)",
366
+ (artifact_id, execution_id, filename, str(target), mime, size),
367
+ )
368
+ return StoredArtifact(
369
+ id=artifact_id,
370
+ execution_id=execution_id,
371
+ filename=filename,
372
+ url=f"file://{target}",
373
+ mime_type=mime,
374
+ size_bytes=size,
375
+ created_at=datetime.utcnow(),
376
+ )
377
+
378
+ async def read_artifact(self, artifact_id_or_url: str) -> bytes:
379
+ path: Path | None = None
380
+ if artifact_id_or_url.startswith("file://"):
381
+ path = Path(artifact_id_or_url[7:])
382
+ else:
383
+ rows = await asyncio.to_thread(
384
+ self._db._query,
385
+ "SELECT path FROM artifacts WHERE id = ?",
386
+ (artifact_id_or_url,),
387
+ )
388
+ if rows:
389
+ path = Path(rows[0]["path"])
390
+
391
+ if not path or not path.exists():
392
+ msg = f"Artifact not found: {artifact_id_or_url}"
393
+ raise FileNotFoundError(msg)
394
+ return await asyncio.to_thread(path.read_bytes)
395
+
396
+ async def list_artifacts(self, execution_id: str) -> list[StoredArtifact]:
397
+ rows = await asyncio.to_thread(
398
+ self._db._query,
399
+ "SELECT * FROM artifacts WHERE execution_id = ? ORDER BY created_at ASC",
400
+ (execution_id,),
401
+ )
402
+ return [
403
+ StoredArtifact(
404
+ id=r["id"],
405
+ execution_id=r["execution_id"],
406
+ filename=r["filename"],
407
+ url=f"file://{r['path']}",
408
+ mime_type=r["mime_type"],
409
+ size_bytes=r["size_bytes"],
410
+ created_at=_parse_dt(r["created_at"]) or datetime.utcnow(),
411
+ )
412
+ for r in rows
413
+ ]
414
+
415
+ async def delete_artifacts(self, execution_id: str) -> int:
416
+ # Delete files
417
+ project_dir = self.projects_root / execution_id
418
+ if project_dir.exists():
419
+ await asyncio.to_thread(shutil.rmtree, project_dir, ignore_errors=True)
420
+ # Delete metadata rows
421
+ rows = await asyncio.to_thread(
422
+ self._db._query,
423
+ "SELECT COUNT(*) AS n FROM artifacts WHERE execution_id = ?",
424
+ (execution_id,),
425
+ )
426
+ count = rows[0]["n"] if rows else 0
427
+ await asyncio.to_thread(
428
+ self._db._execute,
429
+ "DELETE FROM artifacts WHERE execution_id = ?",
430
+ (execution_id,),
431
+ )
432
+ return count
@@ -0,0 +1,174 @@
1
+ """LovarchStorage — StorageClient via Supabase Storage API.
2
+
3
+ Uploads CLI deliverables to the existing 'user-assets' bucket under a
4
+ prefix `lovarch-cli/{execution_id}/{filename}`. This avoids creating a
5
+ new bucket for the alpha; Story 5.x can spin up a dedicated bucket if
6
+ volume warrants.
7
+
8
+ Auth: Bearer user token (Supabase Storage RLS allows authenticated users
9
+ to write under their own paths if the bucket is configured for it).
10
+
11
+ NOTE: actual bucket-level RLS for lovarch-cli prefix needs a follow-up
12
+ migration (Story 5.x). For alpha, we rely on user-assets being permissive
13
+ for authenticated writes. If RLS denies, surfaces as a clear 403 error.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import mimetypes
18
+ import uuid
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ from lovarch_cli.auth.session import LovarchSession, LovarchSessionError
23
+ from lovarch_cli.clients.storage import StorageClient, StoredArtifact
24
+
25
+ DEFAULT_BUCKET = "user-assets"
26
+ PATH_PREFIX = "lovarch-cli"
27
+
28
+
29
+ def _object_path(execution_id: str, filename: str) -> str:
30
+ return f"{PATH_PREFIX}/{execution_id}/{filename}"
31
+
32
+
33
+ class LovarchStorage(StorageClient):
34
+ """Premium-mode storage backend via Supabase Storage."""
35
+
36
+ def __init__(
37
+ self, session: LovarchSession, bucket: str = DEFAULT_BUCKET
38
+ ) -> None:
39
+ self._session = session
40
+ self._bucket = bucket
41
+
42
+ def _public_url(self, object_path: str) -> str:
43
+ # Public URL pattern (does not require signing for public bucket;
44
+ # private buckets need signed URLs but we use user-assets which is
45
+ # configured for authenticated read in Lovarch).
46
+ api_base = self._session._api_url # noqa: SLF001 (intentional)
47
+ return f"{api_base}/storage/v1/object/public/{self._bucket}/{object_path}"
48
+
49
+ async def save_artifact(
50
+ self,
51
+ execution_id: str,
52
+ filename: str,
53
+ data: bytes | Path,
54
+ mime_type: str | None = None,
55
+ ) -> StoredArtifact:
56
+ path = _object_path(execution_id, filename)
57
+ if isinstance(data, Path):
58
+ payload = data.read_bytes()
59
+ else:
60
+ payload = data
61
+
62
+ mime = mime_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
63
+
64
+ upload_url = f"/storage/v1/object/{self._bucket}/{path}"
65
+ response = await self._session.request(
66
+ "POST",
67
+ upload_url,
68
+ content=payload,
69
+ headers={
70
+ "Content-Type": mime,
71
+ "x-upsert": "true",
72
+ },
73
+ )
74
+
75
+ if response.status_code >= 400:
76
+ msg = (
77
+ f"Supabase Storage upload failed: HTTP {response.status_code} "
78
+ f"{response.text[:300]}"
79
+ )
80
+ raise LovarchSessionError(msg)
81
+
82
+ return StoredArtifact(
83
+ id=str(uuid.uuid4()),
84
+ execution_id=execution_id,
85
+ filename=filename,
86
+ url=self._public_url(path),
87
+ mime_type=mime,
88
+ size_bytes=len(payload),
89
+ created_at=datetime.utcnow(),
90
+ )
91
+
92
+ async def read_artifact(self, artifact_id_or_url: str) -> bytes:
93
+ # If we got a Supabase URL, extract the object path; else treat as
94
+ # raw object path (caller knows bucket).
95
+ if artifact_id_or_url.startswith("http"):
96
+ marker = f"/object/public/{self._bucket}/"
97
+ idx = artifact_id_or_url.find(marker)
98
+ if idx < 0:
99
+ marker_priv = f"/object/{self._bucket}/"
100
+ idx = artifact_id_or_url.find(marker_priv)
101
+ if idx < 0:
102
+ msg = f"Cannot extract object path from URL: {artifact_id_or_url}"
103
+ raise LovarchSessionError(msg)
104
+ path = artifact_id_or_url[idx + len(marker_priv) :]
105
+ else:
106
+ path = artifact_id_or_url[idx + len(marker) :]
107
+ else:
108
+ path = artifact_id_or_url
109
+
110
+ response = await self._session.request(
111
+ "GET", f"/storage/v1/object/{self._bucket}/{path}"
112
+ )
113
+ if response.status_code >= 400:
114
+ msg = (
115
+ f"Supabase Storage read failed: HTTP {response.status_code} "
116
+ f"{response.text[:300]}"
117
+ )
118
+ raise LovarchSessionError(msg)
119
+ return response.content
120
+
121
+ async def list_artifacts(self, execution_id: str) -> list[StoredArtifact]:
122
+ prefix_path = _object_path(execution_id, "").rstrip("/")
123
+ response = await self._session.request(
124
+ "POST",
125
+ f"/storage/v1/object/list/{self._bucket}",
126
+ json={"prefix": prefix_path, "limit": 1000, "offset": 0},
127
+ )
128
+ if response.status_code >= 400:
129
+ msg = (
130
+ f"Supabase Storage list failed: HTTP {response.status_code} "
131
+ f"{response.text[:300]}"
132
+ )
133
+ raise LovarchSessionError(msg)
134
+ items = response.json() or []
135
+ artifacts: list[StoredArtifact] = []
136
+ for item in items:
137
+ name = item.get("name", "")
138
+ if not name:
139
+ continue
140
+ full_path = f"{prefix_path}/{name}"
141
+ artifacts.append(
142
+ StoredArtifact(
143
+ id=str(item.get("id") or uuid.uuid4()),
144
+ execution_id=execution_id,
145
+ filename=name,
146
+ url=self._public_url(full_path),
147
+ mime_type=item.get("metadata", {}).get("mimetype"),
148
+ size_bytes=int(item.get("metadata", {}).get("size", 0)),
149
+ created_at=datetime.utcnow(),
150
+ )
151
+ )
152
+ return artifacts
153
+
154
+ async def delete_artifacts(self, execution_id: str) -> int:
155
+ # List first, then bulk delete
156
+ artifacts = await self.list_artifacts(execution_id)
157
+ if not artifacts:
158
+ return 0
159
+
160
+ prefix_path = _object_path(execution_id, "").rstrip("/")
161
+ paths = [f"{prefix_path}/{a.filename}" for a in artifacts]
162
+
163
+ response = await self._session.request(
164
+ "DELETE",
165
+ f"/storage/v1/object/{self._bucket}",
166
+ json={"prefixes": paths},
167
+ )
168
+ if response.status_code >= 400:
169
+ msg = (
170
+ f"Supabase Storage delete failed: HTTP {response.status_code} "
171
+ f"{response.text[:300]}"
172
+ )
173
+ raise LovarchSessionError(msg)
174
+ return len(paths)