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.
- lovarch_cli/__init__.py +16 -0
- lovarch_cli/__main__.py +10 -0
- lovarch_cli/ai/__init__.py +21 -0
- lovarch_cli/ai/gateway.py +240 -0
- lovarch_cli/api.py +111 -0
- lovarch_cli/auth/__init__.py +32 -0
- lovarch_cli/auth/keyring_store.py +214 -0
- lovarch_cli/auth/local_server.py +165 -0
- lovarch_cli/auth/pkce.py +57 -0
- lovarch_cli/auth/session.py +189 -0
- lovarch_cli/cli.py +262 -0
- lovarch_cli/clients/__init__.py +33 -0
- lovarch_cli/clients/factory.py +54 -0
- lovarch_cli/clients/local_client.py +432 -0
- lovarch_cli/clients/lovarch_storage.py +174 -0
- lovarch_cli/clients/lovarch_supabase.py +295 -0
- lovarch_cli/clients/persistence.py +166 -0
- lovarch_cli/clients/storage.py +66 -0
- lovarch_cli/commands/__init__.py +10 -0
- lovarch_cli/commands/account.py +172 -0
- lovarch_cli/commands/audit.py +394 -0
- lovarch_cli/commands/config_cmd.py +80 -0
- lovarch_cli/commands/consolidate.py +217 -0
- lovarch_cli/commands/context_cmd.py +73 -0
- lovarch_cli/commands/dev.py +287 -0
- lovarch_cli/commands/do_cmd.py +120 -0
- lovarch_cli/commands/init.py +218 -0
- lovarch_cli/commands/jobs_cmd.py +95 -0
- lovarch_cli/commands/login.py +202 -0
- lovarch_cli/commands/mcp_cmd.py +26 -0
- lovarch_cli/commands/run.py +375 -0
- lovarch_cli/commands/signup.py +185 -0
- lovarch_cli/commands/status.py +243 -0
- lovarch_cli/commands/upgrade.py +108 -0
- lovarch_cli/commands/verifica_cmd.py +174 -0
- lovarch_cli/config.py +101 -0
- lovarch_cli/config_store.py +111 -0
- lovarch_cli/credits/__init__.py +35 -0
- lovarch_cli/credits/base.py +84 -0
- lovarch_cli/credits/factory.py +36 -0
- lovarch_cli/credits/local.py +34 -0
- lovarch_cli/credits/lovarch.py +56 -0
- lovarch_cli/i18n/__init__.py +27 -0
- lovarch_cli/i18n/loader.py +121 -0
- lovarch_cli/i18n/translations/en.json +168 -0
- lovarch_cli/i18n/translations/es.json +168 -0
- lovarch_cli/i18n/translations/it.json +168 -0
- lovarch_cli/i18n/translations/pt.json +168 -0
- lovarch_cli/mcp/__init__.py +9 -0
- lovarch_cli/mcp/server.py +199 -0
- lovarch_cli/mcp/tools.py +372 -0
- lovarch_cli/sample_downloader.py +255 -0
- lovarch_cli/squad/README.md +206 -0
- lovarch_cli/squad/agents/auditor-input.md +353 -0
- lovarch_cli/squad/agents/bim-engineer.md +404 -0
- lovarch_cli/squad/agents/briefing-architect.md +249 -0
- lovarch_cli/squad/agents/cad-engineer.md +278 -0
- lovarch_cli/squad/agents/capitolato-writer.md +256 -0
- lovarch_cli/squad/agents/computo-engineer.md +258 -0
- lovarch_cli/squad/agents/concept-designer.md +399 -0
- lovarch_cli/squad/agents/contratto-architect.md +243 -0
- lovarch_cli/squad/agents/deliverable-builder.md +253 -0
- lovarch_cli/squad/agents/energy-prelim.md +388 -0
- lovarch_cli/squad/agents/pratiche-it.md +251 -0
- lovarch_cli/squad/agents/progetto-chief.md +768 -0
- lovarch_cli/squad/agents/quality-dati.md +409 -0
- lovarch_cli/squad/agents/quality-misure.md +418 -0
- lovarch_cli/squad/agents/quality-normativa.md +417 -0
- lovarch_cli/squad/agents/quality-output.md +436 -0
- lovarch_cli/squad/agents/regolatorio-it.md +278 -0
- lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
- lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
- lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
- lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
- lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
- lovarch_cli/squad/config.yaml +408 -0
- lovarch_cli/squad/data/CHANGELOG.md +272 -0
- lovarch_cli/squad/data/agents-prd.md +428 -0
- lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
- lovarch_cli/squad/data/handoff-card-template.md +231 -0
- lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
- lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
- lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
- lovarch_cli/squad/scripts/api_clients.py +206 -0
- lovarch_cli/squad/scripts/architect_profile.py +276 -0
- lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
- lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
- lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
- lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
- lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
- lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
- lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
- lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
- lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
- lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
- lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
- lovarch_cli/squad/scripts/validate-squad.py +383 -0
- lovarch_cli/squad/tasks/audit-input.md +146 -0
- lovarch_cli/squad/tasks/compute-metric.md +105 -0
- lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
- lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
- lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
- lovarch_cli/squad/tasks/write-capitolato.md +100 -0
- lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
- lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
- lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
- lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
- lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
- lovarch_cli/squad_loader.py +114 -0
- lovarch_cli/verify/__init__.py +15 -0
- lovarch_cli/verify/contratto.py +110 -0
- lovarch_cli/verify/dossier.py +97 -0
- lovarch_cli/verify/misure.py +83 -0
- lovarch_cli/verify/normativa.py +178 -0
- lovarch_cli/version.py +13 -0
- lovarch_cli/workflows/__init__.py +9 -0
- lovarch_cli/workflows/platform.py +212 -0
- lovarch_cli-0.2.1.dist-info/METADATA +232 -0
- lovarch_cli-0.2.1.dist-info/RECORD +122 -0
- lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
- lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
- 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)
|