sutra-engine 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.
- frontend/__init__.py +0 -0
- frontend/api/__init__.py +0 -0
- frontend/api/jobs.py +686 -0
- frontend/api/main.py +263 -0
- frontend/api/utils.py +13 -0
- pipelines/__init__.py +0 -0
- pipelines/_common.py +129 -0
- pipelines/full_index.py +152 -0
- pipelines/incremental_update.py +108 -0
- sutra/__init__.py +0 -0
- sutra/cli/__init__.py +10 -0
- sutra/cli/config_io.py +263 -0
- sutra/cli/detect.py +203 -0
- sutra/cli/doctor.py +129 -0
- sutra/cli/embed_setup.py +417 -0
- sutra/cli/init.py +232 -0
- sutra/cli/main.py +206 -0
- sutra/cli/provision.py +216 -0
- sutra/cli/remove.py +189 -0
- sutra/core/__init__.py +0 -0
- sutra/core/artifact/__init__.py +22 -0
- sutra/core/artifact/atomic_writer.py +91 -0
- sutra/core/artifact/loader.py +158 -0
- sutra/core/artifact/sink.py +23 -0
- sutra/core/embedder/__init__.py +0 -0
- sutra/core/embedder/base.py +67 -0
- sutra/core/embedder/chunk_builder.py +347 -0
- sutra/core/embedder/factory.py +116 -0
- sutra/core/embedder/fixture.py +46 -0
- sutra/core/embedder/local.py +129 -0
- sutra/core/embedder/openai.py +149 -0
- sutra/core/extractor/__init__.py +0 -0
- sutra/core/extractor/adapters/__init__.py +0 -0
- sutra/core/extractor/adapters/go.py +1019 -0
- sutra/core/extractor/adapters/python.py +999 -0
- sutra/core/extractor/adapters/typescript.py +1302 -0
- sutra/core/extractor/base.py +230 -0
- sutra/core/extractor/moniker.py +229 -0
- sutra/core/extractor/tree_sitter_runner.py +21 -0
- sutra/core/git_differ.py +121 -0
- sutra/core/git_metadata.py +41 -0
- sutra/core/gitignore_filter.py +128 -0
- sutra/core/graph/__init__.py +0 -0
- sutra/core/graph/base.py +167 -0
- sutra/core/graph/migrations/__init__.py +7 -0
- sutra/core/graph/pgvector_store.py +284 -0
- sutra/core/graph/schema.py +15 -0
- sutra/core/graph/sql_reader.py +80 -0
- sutra/core/graph/sql_state.py +82 -0
- sutra/core/graph/sql_writer.py +381 -0
- sutra/core/graph/traversal.py +144 -0
- sutra/core/incremental_updater.py +489 -0
- sutra/core/indexer.py +416 -0
- sutra/core/output/__init__.py +0 -0
- sutra/core/output/json_graph_exporter.py +251 -0
- sutra/core/resolver/__init__.py +13 -0
- sutra/core/resolver/base.py +50 -0
- sutra/core/resolver/heuristic.py +262 -0
- sutra/core/resolver/lsp_resolver.py +337 -0
- sutra/core/retrieval/__init__.py +15 -0
- sutra/core/retrieval/baseline.py +63 -0
- sutra/core/retrieval/channels/__init__.py +12 -0
- sutra/core/retrieval/channels/base.py +38 -0
- sutra/core/retrieval/channels/bm25_channel.py +98 -0
- sutra/core/retrieval/channels/moniker_channel.py +93 -0
- sutra/core/retrieval/channels/vector_channel.py +43 -0
- sutra/core/retrieval/eval/__init__.py +23 -0
- sutra/core/retrieval/eval/dataset.py +106 -0
- sutra/core/retrieval/eval/harness.py +233 -0
- sutra/core/retrieval/eval/metrics.py +60 -0
- sutra/core/retrieval/expander.py +99 -0
- sutra/core/retrieval/fusion.py +70 -0
- sutra/core/retrieval/kind_filter.py +108 -0
- sutra/core/retrieval/pipeline.py +164 -0
- sutra/core/retrieval/query.py +49 -0
- sutra/core/retrieval/query_analyzer.py +177 -0
- sutra/core/retrieval/reranker.py +125 -0
- sutra/core/retrieval/text.py +46 -0
- sutra/core/retrieval/types.py +32 -0
- sutra/core/vector_store/__init__.py +19 -0
- sutra/core/vector_store/base.py +39 -0
- sutra/core/vector_store/in_memory.py +69 -0
- sutra/mcp/__init__.py +11 -0
- sutra/mcp/__main__.py +71 -0
- sutra/mcp/audit.py +78 -0
- sutra/mcp/registry.py +162 -0
- sutra/mcp/server.py +456 -0
- sutra/mcp/watcher.py +126 -0
- sutra_engine-0.1.0.dist-info/METADATA +678 -0
- sutra_engine-0.1.0.dist-info/RECORD +94 -0
- sutra_engine-0.1.0.dist-info/WHEEL +5 -0
- sutra_engine-0.1.0.dist-info/entry_points.txt +2 -0
- sutra_engine-0.1.0.dist-info/licenses/LICENCE +21 -0
- sutra_engine-0.1.0.dist-info/top_level.txt +3 -0
frontend/__init__.py
ADDED
|
File without changes
|
frontend/api/__init__.py
ADDED
|
File without changes
|
frontend/api/jobs.py
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sutra.core.extractor.moniker import repo_dir_slug, repo_name_from_url
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
JOB_STATUSES_FINISHED = {"succeeded", "failed", "cancelled"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _child_env() -> dict[str, str]:
|
|
23
|
+
"""Child indexing processes run JSON-only — never let them inherit
|
|
24
|
+
SUTRA_PG_URL, or full_index's env fallback would silently re-enable
|
|
25
|
+
Postgres writes the frontend deliberately omits."""
|
|
26
|
+
env = os.environ.copy()
|
|
27
|
+
env.pop("SUTRA_PG_URL", None)
|
|
28
|
+
return env
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _full_index_cmd(
|
|
32
|
+
python: str, root: Path, repo_url: str, output_dir: Path,
|
|
33
|
+
config: Path, replace: bool,
|
|
34
|
+
) -> list[str]:
|
|
35
|
+
"""Build the JSON-only full_index command (no --pg-url; LSP on)."""
|
|
36
|
+
cmd = [
|
|
37
|
+
python, "-m", "pipelines.full_index",
|
|
38
|
+
"--root", str(root),
|
|
39
|
+
"--repo-url", repo_url,
|
|
40
|
+
"--output-dir", str(output_dir),
|
|
41
|
+
"--config", str(config),
|
|
42
|
+
"--resolver", "lsp",
|
|
43
|
+
]
|
|
44
|
+
if replace:
|
|
45
|
+
cmd.append("--replace")
|
|
46
|
+
return cmd
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class JobRuntime:
|
|
51
|
+
id: str
|
|
52
|
+
log_buffer: deque[dict[str, str]]
|
|
53
|
+
subscribers: set[asyncio.Queue[dict[str, Any]]]
|
|
54
|
+
process: asyncio.subprocess.Process | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class JobManager:
|
|
58
|
+
def __init__(self, repo_root: Path, sutra_home: Path, artifacts_root: Path) -> None:
|
|
59
|
+
self.repo_root = repo_root
|
|
60
|
+
self.sutra_home = sutra_home
|
|
61
|
+
self.artifacts_root = artifacts_root
|
|
62
|
+
self.artifacts_root.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
self.jobs_root = sutra_home / "jobs"
|
|
64
|
+
self.db_path = sutra_home / "jobs.db"
|
|
65
|
+
|
|
66
|
+
self._pending: deque[str] = deque()
|
|
67
|
+
self._wake = asyncio.Event()
|
|
68
|
+
self._runtime: dict[str, JobRuntime] = {}
|
|
69
|
+
self._worker_task: asyncio.Task[None] | None = None
|
|
70
|
+
self._lock = asyncio.Lock()
|
|
71
|
+
|
|
72
|
+
async def start(self) -> None:
|
|
73
|
+
self.jobs_root.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
await asyncio.to_thread(self._init_db)
|
|
75
|
+
await asyncio.to_thread(self._mark_stale_running_jobs)
|
|
76
|
+
self._worker_task = asyncio.create_task(self._worker_loop())
|
|
77
|
+
|
|
78
|
+
async def stop(self) -> None:
|
|
79
|
+
if self._worker_task:
|
|
80
|
+
self._worker_task.cancel()
|
|
81
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
82
|
+
await self._worker_task
|
|
83
|
+
|
|
84
|
+
async def enqueue(self, repo_url: str, replace: bool) -> dict[str, Any]:
|
|
85
|
+
now = _now_iso()
|
|
86
|
+
repo_slug = _repo_slug(repo_url)
|
|
87
|
+
stamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
|
88
|
+
job_id = f"{repo_slug}-{stamp}"
|
|
89
|
+
|
|
90
|
+
clone_path = Path("/tmp") / "sutra-jobs" / job_id / "repo"
|
|
91
|
+
output_path = self.artifacts_root / repo_dir_slug(repo_name_from_url(repo_url))
|
|
92
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
async with self._lock:
|
|
95
|
+
self._pending.append(job_id)
|
|
96
|
+
queue_position = len(self._pending)
|
|
97
|
+
await asyncio.to_thread(
|
|
98
|
+
self._insert_job,
|
|
99
|
+
job_id,
|
|
100
|
+
repo_url,
|
|
101
|
+
int(replace),
|
|
102
|
+
"queued",
|
|
103
|
+
queue_position,
|
|
104
|
+
str(clone_path),
|
|
105
|
+
str(output_path),
|
|
106
|
+
now,
|
|
107
|
+
)
|
|
108
|
+
runtime = JobRuntime(
|
|
109
|
+
id=job_id,
|
|
110
|
+
log_buffer=deque(maxlen=10000),
|
|
111
|
+
subscribers=set(),
|
|
112
|
+
process=None,
|
|
113
|
+
)
|
|
114
|
+
self._runtime[job_id] = runtime
|
|
115
|
+
await self._recompute_queue_positions_locked()
|
|
116
|
+
self._wake.set()
|
|
117
|
+
|
|
118
|
+
return {"jobId": job_id, "queuePosition": queue_position}
|
|
119
|
+
|
|
120
|
+
async def get_job(self, job_id: str) -> dict[str, Any] | None:
|
|
121
|
+
return await asyncio.to_thread(self._get_job, job_id)
|
|
122
|
+
|
|
123
|
+
async def list_jobs(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
124
|
+
return await asyncio.to_thread(self._list_jobs, limit)
|
|
125
|
+
|
|
126
|
+
async def cost_summary(self) -> dict[str, Any]:
|
|
127
|
+
return await asyncio.to_thread(self._cost_summary)
|
|
128
|
+
|
|
129
|
+
async def subscribe(self, job_id: str) -> tuple[JobRuntime | None, asyncio.Queue[dict[str, Any]]]:
|
|
130
|
+
async with self._lock:
|
|
131
|
+
runtime = self._runtime.get(job_id)
|
|
132
|
+
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=1000)
|
|
133
|
+
if runtime is None:
|
|
134
|
+
return None, q
|
|
135
|
+
runtime.subscribers.add(q)
|
|
136
|
+
return runtime, q
|
|
137
|
+
|
|
138
|
+
async def unsubscribe(self, job_id: str, q: asyncio.Queue[dict[str, Any]]) -> None:
|
|
139
|
+
async with self._lock:
|
|
140
|
+
runtime = self._runtime.get(job_id)
|
|
141
|
+
if runtime is not None:
|
|
142
|
+
runtime.subscribers.discard(q)
|
|
143
|
+
|
|
144
|
+
async def cancel(self, job_id: str) -> tuple[int, dict[str, Any]]:
|
|
145
|
+
async with self._lock:
|
|
146
|
+
job = await asyncio.to_thread(self._get_job, job_id)
|
|
147
|
+
if not job:
|
|
148
|
+
return 404, {"detail": "job not found"}
|
|
149
|
+
if job["status"] in JOB_STATUSES_FINISHED:
|
|
150
|
+
return 409, {"detail": "job already finished"}
|
|
151
|
+
|
|
152
|
+
if job_id in self._pending:
|
|
153
|
+
self._pending = deque([j for j in self._pending if j != job_id])
|
|
154
|
+
await asyncio.to_thread(
|
|
155
|
+
self._update_job_status,
|
|
156
|
+
job_id,
|
|
157
|
+
"cancelled",
|
|
158
|
+
1,
|
|
159
|
+
"Cancelled while queued",
|
|
160
|
+
None,
|
|
161
|
+
)
|
|
162
|
+
runtime = self._runtime.get(job_id)
|
|
163
|
+
if runtime:
|
|
164
|
+
await self._publish(runtime, "completed", await asyncio.to_thread(self._get_job, job_id))
|
|
165
|
+
await self._recompute_queue_positions_locked()
|
|
166
|
+
return 200, {"status": "cancelled"}
|
|
167
|
+
|
|
168
|
+
runtime = self._runtime.get(job_id)
|
|
169
|
+
if not runtime or not runtime.process or runtime.process.returncode is not None:
|
|
170
|
+
return 409, {"detail": "job is not running"}
|
|
171
|
+
|
|
172
|
+
runtime.process.terminate()
|
|
173
|
+
asyncio.create_task(self._force_kill_if_needed(job_id, runtime.process, timeout_sec=10))
|
|
174
|
+
self._append_log_locked(runtime, "system", "Cancellation requested: SIGTERM sent")
|
|
175
|
+
return 200, {"status": "cancelling"}
|
|
176
|
+
|
|
177
|
+
async def _force_kill_if_needed(
|
|
178
|
+
self, job_id: str, process: asyncio.subprocess.Process, timeout_sec: int
|
|
179
|
+
) -> None:
|
|
180
|
+
try:
|
|
181
|
+
await asyncio.wait_for(process.wait(), timeout=timeout_sec)
|
|
182
|
+
except asyncio.TimeoutError:
|
|
183
|
+
with contextlib.suppress(ProcessLookupError):
|
|
184
|
+
process.kill()
|
|
185
|
+
async with self._lock:
|
|
186
|
+
runtime = self._runtime.get(job_id)
|
|
187
|
+
if runtime:
|
|
188
|
+
self._append_log_locked(runtime, "system", "Process did not stop in 10s: SIGKILL sent")
|
|
189
|
+
|
|
190
|
+
async def _worker_loop(self) -> None:
|
|
191
|
+
while True:
|
|
192
|
+
await self._wake.wait()
|
|
193
|
+
while True:
|
|
194
|
+
async with self._lock:
|
|
195
|
+
if not self._pending:
|
|
196
|
+
self._wake.clear()
|
|
197
|
+
break
|
|
198
|
+
job_id = self._pending.popleft()
|
|
199
|
+
await self._recompute_queue_positions_locked()
|
|
200
|
+
|
|
201
|
+
await self._run_job(job_id)
|
|
202
|
+
|
|
203
|
+
async def _run_job(self, job_id: str) -> None:
|
|
204
|
+
job = await asyncio.to_thread(self._get_job, job_id)
|
|
205
|
+
if not job:
|
|
206
|
+
return
|
|
207
|
+
if job["status"] == "cancelled":
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
started_at = _now_iso()
|
|
211
|
+
await asyncio.to_thread(self._set_running, job_id, started_at)
|
|
212
|
+
|
|
213
|
+
async with self._lock:
|
|
214
|
+
runtime = self._runtime[job_id]
|
|
215
|
+
await self._publish(runtime, "started", await asyncio.to_thread(self._get_job, job_id))
|
|
216
|
+
|
|
217
|
+
clone_path = Path(job["clone_path"])
|
|
218
|
+
output_path = Path(job["output_path"])
|
|
219
|
+
repo_url = job["repo_url"]
|
|
220
|
+
replace = bool(job["replace"])
|
|
221
|
+
|
|
222
|
+
clone_path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
exit_code = 1
|
|
226
|
+
error = None
|
|
227
|
+
error_detail = None
|
|
228
|
+
summary_json = None
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
runtime = self._runtime[job_id]
|
|
232
|
+
clone_cmd = ["git", "clone", repo_url, str(clone_path)]
|
|
233
|
+
clone_rc = await self._run_cmd(job_id, runtime, clone_cmd)
|
|
234
|
+
if clone_rc != 0:
|
|
235
|
+
raise RuntimeError(f"git clone failed with exit code {clone_rc}")
|
|
236
|
+
|
|
237
|
+
cmd = _full_index_cmd(
|
|
238
|
+
python=sys.executable,
|
|
239
|
+
root=clone_path,
|
|
240
|
+
repo_url=repo_url,
|
|
241
|
+
output_dir=output_path,
|
|
242
|
+
config=self.repo_root / "config" / "sutra.yaml",
|
|
243
|
+
replace=replace,
|
|
244
|
+
)
|
|
245
|
+
exit_code = await self._run_cmd(job_id, runtime, cmd)
|
|
246
|
+
graph_path = output_path / "graph.json"
|
|
247
|
+
if graph_path.exists():
|
|
248
|
+
summary_json = self._parse_summary_json(graph_path)
|
|
249
|
+
if exit_code == 0:
|
|
250
|
+
status = "succeeded"
|
|
251
|
+
if summary_json:
|
|
252
|
+
summary = json.loads(summary_json)
|
|
253
|
+
else:
|
|
254
|
+
summary = {}
|
|
255
|
+
fallback = self._extract_usage_from_logs(runtime.log_buffer)
|
|
256
|
+
if fallback:
|
|
257
|
+
summary.setdefault("embedding_total_tokens", fallback.get("embedding_total_tokens"))
|
|
258
|
+
summary.setdefault("embedding_estimated_cost_usd", fallback.get("embedding_estimated_cost_usd"))
|
|
259
|
+
summary_json = json.dumps(summary)
|
|
260
|
+
if summary_json:
|
|
261
|
+
with contextlib.suppress(json.JSONDecodeError, TypeError, ValueError):
|
|
262
|
+
summary = json.loads(summary_json)
|
|
263
|
+
tokens = summary.get("embedding_total_tokens")
|
|
264
|
+
cost = summary.get("embedding_estimated_cost_usd")
|
|
265
|
+
if tokens is not None:
|
|
266
|
+
async with self._lock:
|
|
267
|
+
self._append_log_locked(
|
|
268
|
+
runtime,
|
|
269
|
+
"system",
|
|
270
|
+
f"Embedding tokens used: {tokens}",
|
|
271
|
+
)
|
|
272
|
+
if cost is not None:
|
|
273
|
+
async with self._lock:
|
|
274
|
+
self._append_log_locked(
|
|
275
|
+
runtime,
|
|
276
|
+
"system",
|
|
277
|
+
f"Estimated embedding cost (USD): {float(cost):.8f}",
|
|
278
|
+
)
|
|
279
|
+
elif exit_code < 0:
|
|
280
|
+
status = "cancelled"
|
|
281
|
+
error = f"terminated by signal {-exit_code}"
|
|
282
|
+
else:
|
|
283
|
+
status = "failed"
|
|
284
|
+
error = f"pipeline exited with code {exit_code}"
|
|
285
|
+
error_detail = self._extract_failure_detail(runtime.log_buffer)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
status = "failed"
|
|
288
|
+
error = str(exc)
|
|
289
|
+
runtime = self._runtime.get(job_id)
|
|
290
|
+
if runtime:
|
|
291
|
+
error_detail = self._extract_failure_detail(runtime.log_buffer)
|
|
292
|
+
finally:
|
|
293
|
+
shutil.rmtree(clone_path.parent, ignore_errors=True)
|
|
294
|
+
|
|
295
|
+
finished_at = _now_iso()
|
|
296
|
+
await asyncio.to_thread(
|
|
297
|
+
self._set_finished,
|
|
298
|
+
job_id,
|
|
299
|
+
status,
|
|
300
|
+
finished_at,
|
|
301
|
+
exit_code,
|
|
302
|
+
error,
|
|
303
|
+
error_detail,
|
|
304
|
+
summary_json,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async with self._lock:
|
|
308
|
+
runtime = self._runtime.get(job_id)
|
|
309
|
+
if runtime:
|
|
310
|
+
await self._publish(runtime, "completed", await asyncio.to_thread(self._get_job, job_id))
|
|
311
|
+
|
|
312
|
+
async def _run_cmd(self, job_id: str, runtime: JobRuntime, cmd: list[str]) -> int:
|
|
313
|
+
proc = await asyncio.create_subprocess_exec(
|
|
314
|
+
*cmd,
|
|
315
|
+
cwd=str(self.repo_root),
|
|
316
|
+
stdout=asyncio.subprocess.PIPE,
|
|
317
|
+
stderr=asyncio.subprocess.PIPE,
|
|
318
|
+
env=_child_env(),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
await asyncio.to_thread(self._set_pid, job_id, proc.pid)
|
|
322
|
+
|
|
323
|
+
async with self._lock:
|
|
324
|
+
runtime.process = proc
|
|
325
|
+
self._append_log_locked(runtime, "system", "$ " + " ".join(cmd))
|
|
326
|
+
|
|
327
|
+
async def pump(stream: asyncio.StreamReader, source: str) -> None:
|
|
328
|
+
while True:
|
|
329
|
+
line = await stream.readline()
|
|
330
|
+
if not line:
|
|
331
|
+
break
|
|
332
|
+
text = line.decode("utf-8", errors="replace").rstrip("\n")
|
|
333
|
+
if text:
|
|
334
|
+
out = sys.stderr if source == "stderr" else sys.stdout
|
|
335
|
+
print(f"[job {job_id}][{source}] {text}", file=out, flush=True)
|
|
336
|
+
async with self._lock:
|
|
337
|
+
self._append_log_locked(runtime, source, text)
|
|
338
|
+
|
|
339
|
+
stdout_task = asyncio.create_task(pump(proc.stdout, "stdout"))
|
|
340
|
+
stderr_task = asyncio.create_task(pump(proc.stderr, "stderr"))
|
|
341
|
+
await asyncio.gather(stdout_task, stderr_task)
|
|
342
|
+
rc = await proc.wait()
|
|
343
|
+
|
|
344
|
+
async with self._lock:
|
|
345
|
+
runtime.process = None
|
|
346
|
+
|
|
347
|
+
return rc
|
|
348
|
+
|
|
349
|
+
def _parse_summary_json(self, graph_path: Path) -> str | None:
|
|
350
|
+
try:
|
|
351
|
+
with graph_path.open("r", encoding="utf-8") as f:
|
|
352
|
+
data = json.load(f)
|
|
353
|
+
repo = data.get("repository") or {}
|
|
354
|
+
usage = data.get("embedding_usage") or {}
|
|
355
|
+
summary = {
|
|
356
|
+
"symbol_count": len(data.get("symbols") or []),
|
|
357
|
+
"file_count": len(data.get("files") or []),
|
|
358
|
+
"languages": repo.get("languages") or {},
|
|
359
|
+
"commit_sha": repo.get("commit_sha") or repo.get("commit_hash"),
|
|
360
|
+
"embedding_provider": usage.get("provider"),
|
|
361
|
+
"embedding_model": usage.get("model"),
|
|
362
|
+
"embedding_prompt_tokens": usage.get("prompt_tokens"),
|
|
363
|
+
"embedding_total_tokens": usage.get("total_tokens"),
|
|
364
|
+
"embedding_estimated_cost_usd": usage.get("estimated_cost_usd"),
|
|
365
|
+
}
|
|
366
|
+
return json.dumps(summary)
|
|
367
|
+
except Exception as exc: # keep worker resilient on malformed outputs
|
|
368
|
+
print(f"[sutra-ui] failed to parse graph.json: {exc}", file=sys.stderr)
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
def _extract_usage_from_logs(self, log_buffer: deque[dict[str, str]]) -> dict[str, Any] | None:
|
|
372
|
+
tokens = None
|
|
373
|
+
cost = None
|
|
374
|
+
for item in reversed(log_buffer):
|
|
375
|
+
line = item.get("line", "")
|
|
376
|
+
if tokens is None and "Embedding tokens used:" in line:
|
|
377
|
+
with contextlib.suppress(ValueError):
|
|
378
|
+
tokens = int(line.rsplit(":", 1)[1].strip())
|
|
379
|
+
if cost is None and "Estimated embedding cost (USD):" in line:
|
|
380
|
+
with contextlib.suppress(ValueError):
|
|
381
|
+
cost = float(line.rsplit(":", 1)[1].strip())
|
|
382
|
+
if tokens is not None and cost is not None:
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
if tokens is None and cost is None:
|
|
386
|
+
return None
|
|
387
|
+
return {
|
|
388
|
+
"embedding_total_tokens": tokens,
|
|
389
|
+
"embedding_estimated_cost_usd": cost,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
def _extract_failure_detail(self, log_buffer: deque[dict[str, str]]) -> str | None:
|
|
393
|
+
for item in reversed(log_buffer):
|
|
394
|
+
if item.get("source") != "stderr":
|
|
395
|
+
continue
|
|
396
|
+
line = (item.get("line") or "").strip()
|
|
397
|
+
if not line:
|
|
398
|
+
continue
|
|
399
|
+
if line.startswith("File \"") or line.startswith("Traceback ") or line.startswith("^"):
|
|
400
|
+
continue
|
|
401
|
+
return line
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
async def _recompute_queue_positions_locked(self) -> None:
|
|
405
|
+
for pos, pending_id in enumerate(self._pending, start=1):
|
|
406
|
+
await asyncio.to_thread(self._update_queue_position, pending_id, pos)
|
|
407
|
+
|
|
408
|
+
def _append_log_locked(self, runtime: JobRuntime, source: str, line: str) -> None:
|
|
409
|
+
payload = {"source": source, "line": line, "ts": _now_iso()}
|
|
410
|
+
runtime.log_buffer.append(payload)
|
|
411
|
+
for q in list(runtime.subscribers):
|
|
412
|
+
if q.full():
|
|
413
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
414
|
+
q.get_nowait()
|
|
415
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
416
|
+
q.put_nowait({"event": "log", "data": payload})
|
|
417
|
+
|
|
418
|
+
async def _publish(self, runtime: JobRuntime, event: str, data: Any) -> None:
|
|
419
|
+
payload = {"event": event, "data": data}
|
|
420
|
+
for q in list(runtime.subscribers):
|
|
421
|
+
if q.full():
|
|
422
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
423
|
+
q.get_nowait()
|
|
424
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
425
|
+
q.put_nowait(payload)
|
|
426
|
+
|
|
427
|
+
def _connect(self) -> sqlite3.Connection:
|
|
428
|
+
conn = sqlite3.connect(self.db_path)
|
|
429
|
+
conn.row_factory = sqlite3.Row
|
|
430
|
+
return conn
|
|
431
|
+
|
|
432
|
+
def _init_db(self) -> None:
|
|
433
|
+
with self._connect() as conn:
|
|
434
|
+
conn.execute(
|
|
435
|
+
"""
|
|
436
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
437
|
+
id TEXT PRIMARY KEY,
|
|
438
|
+
repo_url TEXT NOT NULL,
|
|
439
|
+
replace INTEGER NOT NULL,
|
|
440
|
+
status TEXT NOT NULL,
|
|
441
|
+
queue_position INTEGER,
|
|
442
|
+
started_at TEXT,
|
|
443
|
+
finished_at TEXT,
|
|
444
|
+
exit_code INTEGER,
|
|
445
|
+
clone_path TEXT,
|
|
446
|
+
output_path TEXT,
|
|
447
|
+
error TEXT,
|
|
448
|
+
error_detail TEXT,
|
|
449
|
+
summary_json TEXT,
|
|
450
|
+
embedding_prompt_tokens INTEGER,
|
|
451
|
+
embedding_total_tokens INTEGER,
|
|
452
|
+
embedding_estimated_cost_usd REAL,
|
|
453
|
+
pid INTEGER,
|
|
454
|
+
created_at TEXT NOT NULL
|
|
455
|
+
)
|
|
456
|
+
"""
|
|
457
|
+
)
|
|
458
|
+
existing = {
|
|
459
|
+
row["name"]
|
|
460
|
+
for row in conn.execute("PRAGMA table_info(jobs)").fetchall()
|
|
461
|
+
}
|
|
462
|
+
if "embedding_prompt_tokens" not in existing:
|
|
463
|
+
conn.execute("ALTER TABLE jobs ADD COLUMN embedding_prompt_tokens INTEGER")
|
|
464
|
+
if "embedding_total_tokens" not in existing:
|
|
465
|
+
conn.execute("ALTER TABLE jobs ADD COLUMN embedding_total_tokens INTEGER")
|
|
466
|
+
if "embedding_estimated_cost_usd" not in existing:
|
|
467
|
+
conn.execute("ALTER TABLE jobs ADD COLUMN embedding_estimated_cost_usd REAL")
|
|
468
|
+
if "error_detail" not in existing:
|
|
469
|
+
conn.execute("ALTER TABLE jobs ADD COLUMN error_detail TEXT")
|
|
470
|
+
conn.commit()
|
|
471
|
+
|
|
472
|
+
def _mark_stale_running_jobs(self) -> None:
|
|
473
|
+
with self._connect() as conn:
|
|
474
|
+
conn.execute(
|
|
475
|
+
"""
|
|
476
|
+
UPDATE jobs
|
|
477
|
+
SET status='failed', error='API restarted mid-run', finished_at=?
|
|
478
|
+
WHERE status='running'
|
|
479
|
+
""",
|
|
480
|
+
(_now_iso(),),
|
|
481
|
+
)
|
|
482
|
+
conn.commit()
|
|
483
|
+
|
|
484
|
+
def _insert_job(
|
|
485
|
+
self,
|
|
486
|
+
job_id: str,
|
|
487
|
+
repo_url: str,
|
|
488
|
+
replace: int,
|
|
489
|
+
status: str,
|
|
490
|
+
queue_position: int,
|
|
491
|
+
clone_path: str,
|
|
492
|
+
output_path: str,
|
|
493
|
+
created_at: str,
|
|
494
|
+
) -> None:
|
|
495
|
+
with self._connect() as conn:
|
|
496
|
+
conn.execute(
|
|
497
|
+
"""
|
|
498
|
+
INSERT INTO jobs
|
|
499
|
+
(id, repo_url, replace, status, queue_position, clone_path, output_path, created_at)
|
|
500
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
501
|
+
""",
|
|
502
|
+
(job_id, repo_url, replace, status, queue_position, clone_path, output_path, created_at),
|
|
503
|
+
)
|
|
504
|
+
conn.commit()
|
|
505
|
+
|
|
506
|
+
def _set_running(self, job_id: str, started_at: str) -> None:
|
|
507
|
+
with self._connect() as conn:
|
|
508
|
+
conn.execute(
|
|
509
|
+
"UPDATE jobs SET status='running', started_at=?, queue_position=NULL WHERE id=?",
|
|
510
|
+
(started_at, job_id),
|
|
511
|
+
)
|
|
512
|
+
conn.commit()
|
|
513
|
+
|
|
514
|
+
def _set_pid(self, job_id: str, pid: int) -> None:
|
|
515
|
+
with self._connect() as conn:
|
|
516
|
+
conn.execute("UPDATE jobs SET pid=? WHERE id=?", (pid, job_id))
|
|
517
|
+
conn.commit()
|
|
518
|
+
|
|
519
|
+
def _set_finished(
|
|
520
|
+
self,
|
|
521
|
+
job_id: str,
|
|
522
|
+
status: str,
|
|
523
|
+
finished_at: str,
|
|
524
|
+
exit_code: int,
|
|
525
|
+
error: str | None,
|
|
526
|
+
error_detail: str | None,
|
|
527
|
+
summary_json: str | None,
|
|
528
|
+
) -> None:
|
|
529
|
+
prompt_tokens = None
|
|
530
|
+
total_tokens = None
|
|
531
|
+
estimated_cost = None
|
|
532
|
+
if summary_json:
|
|
533
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
534
|
+
summary_obj = json.loads(summary_json)
|
|
535
|
+
prompt_tokens = summary_obj.get("embedding_prompt_tokens")
|
|
536
|
+
total_tokens = summary_obj.get("embedding_total_tokens")
|
|
537
|
+
estimated_cost = summary_obj.get("embedding_estimated_cost_usd")
|
|
538
|
+
with self._connect() as conn:
|
|
539
|
+
conn.execute(
|
|
540
|
+
"""
|
|
541
|
+
UPDATE jobs
|
|
542
|
+
SET status=?, finished_at=?, exit_code=?, error=?, summary_json=?,
|
|
543
|
+
error_detail=?,
|
|
544
|
+
embedding_prompt_tokens=?, embedding_total_tokens=?, embedding_estimated_cost_usd=?,
|
|
545
|
+
pid=NULL, queue_position=NULL
|
|
546
|
+
WHERE id=?
|
|
547
|
+
""",
|
|
548
|
+
(
|
|
549
|
+
status,
|
|
550
|
+
finished_at,
|
|
551
|
+
exit_code,
|
|
552
|
+
error,
|
|
553
|
+
summary_json,
|
|
554
|
+
error_detail,
|
|
555
|
+
prompt_tokens,
|
|
556
|
+
total_tokens,
|
|
557
|
+
estimated_cost,
|
|
558
|
+
job_id,
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
conn.commit()
|
|
562
|
+
|
|
563
|
+
def _update_job_status(
|
|
564
|
+
self,
|
|
565
|
+
job_id: str,
|
|
566
|
+
status: str,
|
|
567
|
+
exit_code: int | None,
|
|
568
|
+
error: str | None,
|
|
569
|
+
finished_at: str | None,
|
|
570
|
+
) -> None:
|
|
571
|
+
with self._connect() as conn:
|
|
572
|
+
conn.execute(
|
|
573
|
+
"""
|
|
574
|
+
UPDATE jobs
|
|
575
|
+
SET status=?, exit_code=?, error=?, finished_at=?, pid=NULL, queue_position=NULL
|
|
576
|
+
WHERE id=?
|
|
577
|
+
""",
|
|
578
|
+
(status, exit_code, error, finished_at or _now_iso(), job_id),
|
|
579
|
+
)
|
|
580
|
+
conn.commit()
|
|
581
|
+
|
|
582
|
+
def _update_queue_position(self, job_id: str, position: int) -> None:
|
|
583
|
+
with self._connect() as conn:
|
|
584
|
+
conn.execute(
|
|
585
|
+
"UPDATE jobs SET queue_position=? WHERE id=? AND status='queued'",
|
|
586
|
+
(position, job_id),
|
|
587
|
+
)
|
|
588
|
+
conn.commit()
|
|
589
|
+
|
|
590
|
+
def _get_job(self, job_id: str) -> dict[str, Any] | None:
|
|
591
|
+
with self._connect() as conn:
|
|
592
|
+
row = conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
|
593
|
+
if not row:
|
|
594
|
+
return None
|
|
595
|
+
return _row_to_job(row)
|
|
596
|
+
|
|
597
|
+
def _list_jobs(self, limit: int) -> list[dict[str, Any]]:
|
|
598
|
+
with self._connect() as conn:
|
|
599
|
+
rows = conn.execute(
|
|
600
|
+
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?",
|
|
601
|
+
(limit,),
|
|
602
|
+
).fetchall()
|
|
603
|
+
return [_row_to_job(r) for r in rows]
|
|
604
|
+
|
|
605
|
+
def _cost_summary(self) -> dict[str, Any]:
|
|
606
|
+
with self._connect() as conn:
|
|
607
|
+
row = conn.execute(
|
|
608
|
+
"""
|
|
609
|
+
SELECT
|
|
610
|
+
COUNT(*) AS jobs_count,
|
|
611
|
+
COALESCE(SUM(embedding_total_tokens), 0) AS total_tokens,
|
|
612
|
+
COALESCE(SUM(embedding_estimated_cost_usd), 0) AS total_cost_usd
|
|
613
|
+
FROM jobs
|
|
614
|
+
WHERE status = 'succeeded'
|
|
615
|
+
"""
|
|
616
|
+
).fetchone()
|
|
617
|
+
by_repo_rows = conn.execute(
|
|
618
|
+
"""
|
|
619
|
+
SELECT
|
|
620
|
+
repo_url,
|
|
621
|
+
COUNT(*) AS jobs_count,
|
|
622
|
+
COALESCE(SUM(embedding_total_tokens), 0) AS total_tokens,
|
|
623
|
+
COALESCE(SUM(embedding_estimated_cost_usd), 0) AS total_cost_usd
|
|
624
|
+
FROM jobs
|
|
625
|
+
WHERE status = 'succeeded'
|
|
626
|
+
GROUP BY repo_url
|
|
627
|
+
ORDER BY total_cost_usd DESC, total_tokens DESC
|
|
628
|
+
"""
|
|
629
|
+
).fetchall()
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
"jobs_count": int(row["jobs_count"]),
|
|
633
|
+
"total_tokens": int(row["total_tokens"]),
|
|
634
|
+
"total_cost_usd": float(row["total_cost_usd"]),
|
|
635
|
+
"by_repo": [
|
|
636
|
+
{
|
|
637
|
+
"repo_url": r["repo_url"],
|
|
638
|
+
"jobs_count": int(r["jobs_count"]),
|
|
639
|
+
"total_tokens": int(r["total_tokens"]),
|
|
640
|
+
"total_cost_usd": float(r["total_cost_usd"]),
|
|
641
|
+
}
|
|
642
|
+
for r in by_repo_rows
|
|
643
|
+
],
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
|
|
648
|
+
summary = None
|
|
649
|
+
if row["summary_json"]:
|
|
650
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
651
|
+
summary = json.loads(row["summary_json"])
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
"id": row["id"],
|
|
655
|
+
"repo_url": row["repo_url"],
|
|
656
|
+
"clone_path": row["clone_path"],
|
|
657
|
+
"output_path": row["output_path"],
|
|
658
|
+
"repoUrl": row["repo_url"],
|
|
659
|
+
"replace": bool(row["replace"]),
|
|
660
|
+
"status": row["status"],
|
|
661
|
+
"queuePosition": row["queue_position"],
|
|
662
|
+
"startedAt": row["started_at"],
|
|
663
|
+
"finishedAt": row["finished_at"],
|
|
664
|
+
"exitCode": row["exit_code"],
|
|
665
|
+
"clonePath": row["clone_path"],
|
|
666
|
+
"outputPath": row["output_path"],
|
|
667
|
+
"error": row["error"],
|
|
668
|
+
"errorDetail": row["error_detail"],
|
|
669
|
+
"summary": summary,
|
|
670
|
+
"embeddingPromptTokens": row["embedding_prompt_tokens"],
|
|
671
|
+
"embeddingTotalTokens": row["embedding_total_tokens"],
|
|
672
|
+
"embeddingEstimatedCostUsd": row["embedding_estimated_cost_usd"],
|
|
673
|
+
"pid": row["pid"],
|
|
674
|
+
"createdAt": row["created_at"],
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _repo_slug(repo_url: str) -> str:
|
|
679
|
+
raw = repo_url.rstrip("/").split("/")[-1]
|
|
680
|
+
raw = raw.removesuffix(".git")
|
|
681
|
+
clean = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in raw)
|
|
682
|
+
return clean or "repo"
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _now_iso() -> str:
|
|
686
|
+
return datetime.now(tz=timezone.utc).isoformat()
|