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.
Files changed (94) hide show
  1. frontend/__init__.py +0 -0
  2. frontend/api/__init__.py +0 -0
  3. frontend/api/jobs.py +686 -0
  4. frontend/api/main.py +263 -0
  5. frontend/api/utils.py +13 -0
  6. pipelines/__init__.py +0 -0
  7. pipelines/_common.py +129 -0
  8. pipelines/full_index.py +152 -0
  9. pipelines/incremental_update.py +108 -0
  10. sutra/__init__.py +0 -0
  11. sutra/cli/__init__.py +10 -0
  12. sutra/cli/config_io.py +263 -0
  13. sutra/cli/detect.py +203 -0
  14. sutra/cli/doctor.py +129 -0
  15. sutra/cli/embed_setup.py +417 -0
  16. sutra/cli/init.py +232 -0
  17. sutra/cli/main.py +206 -0
  18. sutra/cli/provision.py +216 -0
  19. sutra/cli/remove.py +189 -0
  20. sutra/core/__init__.py +0 -0
  21. sutra/core/artifact/__init__.py +22 -0
  22. sutra/core/artifact/atomic_writer.py +91 -0
  23. sutra/core/artifact/loader.py +158 -0
  24. sutra/core/artifact/sink.py +23 -0
  25. sutra/core/embedder/__init__.py +0 -0
  26. sutra/core/embedder/base.py +67 -0
  27. sutra/core/embedder/chunk_builder.py +347 -0
  28. sutra/core/embedder/factory.py +116 -0
  29. sutra/core/embedder/fixture.py +46 -0
  30. sutra/core/embedder/local.py +129 -0
  31. sutra/core/embedder/openai.py +149 -0
  32. sutra/core/extractor/__init__.py +0 -0
  33. sutra/core/extractor/adapters/__init__.py +0 -0
  34. sutra/core/extractor/adapters/go.py +1019 -0
  35. sutra/core/extractor/adapters/python.py +999 -0
  36. sutra/core/extractor/adapters/typescript.py +1302 -0
  37. sutra/core/extractor/base.py +230 -0
  38. sutra/core/extractor/moniker.py +229 -0
  39. sutra/core/extractor/tree_sitter_runner.py +21 -0
  40. sutra/core/git_differ.py +121 -0
  41. sutra/core/git_metadata.py +41 -0
  42. sutra/core/gitignore_filter.py +128 -0
  43. sutra/core/graph/__init__.py +0 -0
  44. sutra/core/graph/base.py +167 -0
  45. sutra/core/graph/migrations/__init__.py +7 -0
  46. sutra/core/graph/pgvector_store.py +284 -0
  47. sutra/core/graph/schema.py +15 -0
  48. sutra/core/graph/sql_reader.py +80 -0
  49. sutra/core/graph/sql_state.py +82 -0
  50. sutra/core/graph/sql_writer.py +381 -0
  51. sutra/core/graph/traversal.py +144 -0
  52. sutra/core/incremental_updater.py +489 -0
  53. sutra/core/indexer.py +416 -0
  54. sutra/core/output/__init__.py +0 -0
  55. sutra/core/output/json_graph_exporter.py +251 -0
  56. sutra/core/resolver/__init__.py +13 -0
  57. sutra/core/resolver/base.py +50 -0
  58. sutra/core/resolver/heuristic.py +262 -0
  59. sutra/core/resolver/lsp_resolver.py +337 -0
  60. sutra/core/retrieval/__init__.py +15 -0
  61. sutra/core/retrieval/baseline.py +63 -0
  62. sutra/core/retrieval/channels/__init__.py +12 -0
  63. sutra/core/retrieval/channels/base.py +38 -0
  64. sutra/core/retrieval/channels/bm25_channel.py +98 -0
  65. sutra/core/retrieval/channels/moniker_channel.py +93 -0
  66. sutra/core/retrieval/channels/vector_channel.py +43 -0
  67. sutra/core/retrieval/eval/__init__.py +23 -0
  68. sutra/core/retrieval/eval/dataset.py +106 -0
  69. sutra/core/retrieval/eval/harness.py +233 -0
  70. sutra/core/retrieval/eval/metrics.py +60 -0
  71. sutra/core/retrieval/expander.py +99 -0
  72. sutra/core/retrieval/fusion.py +70 -0
  73. sutra/core/retrieval/kind_filter.py +108 -0
  74. sutra/core/retrieval/pipeline.py +164 -0
  75. sutra/core/retrieval/query.py +49 -0
  76. sutra/core/retrieval/query_analyzer.py +177 -0
  77. sutra/core/retrieval/reranker.py +125 -0
  78. sutra/core/retrieval/text.py +46 -0
  79. sutra/core/retrieval/types.py +32 -0
  80. sutra/core/vector_store/__init__.py +19 -0
  81. sutra/core/vector_store/base.py +39 -0
  82. sutra/core/vector_store/in_memory.py +69 -0
  83. sutra/mcp/__init__.py +11 -0
  84. sutra/mcp/__main__.py +71 -0
  85. sutra/mcp/audit.py +78 -0
  86. sutra/mcp/registry.py +162 -0
  87. sutra/mcp/server.py +456 -0
  88. sutra/mcp/watcher.py +126 -0
  89. sutra_engine-0.1.0.dist-info/METADATA +678 -0
  90. sutra_engine-0.1.0.dist-info/RECORD +94 -0
  91. sutra_engine-0.1.0.dist-info/WHEEL +5 -0
  92. sutra_engine-0.1.0.dist-info/entry_points.txt +2 -0
  93. sutra_engine-0.1.0.dist-info/licenses/LICENCE +21 -0
  94. sutra_engine-0.1.0.dist-info/top_level.txt +3 -0
frontend/__init__.py ADDED
File without changes
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()