File size: 8,150 Bytes
c32102b
2798900
 
 
 
 
 
 
 
 
c32102b
 
2798900
 
c32102b
 
2798900
c32102b
2798900
c32102b
 
 
2798900
c32102b
 
2798900
 
 
 
 
c32102b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2798900
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c32102b
 
 
 
 
 
 
 
 
 
 
 
 
2798900
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c32102b
 
 
2798900
 
 
 
 
 
c32102b
 
 
 
 
 
 
 
 
 
 
 
 
2798900
 
 
 
 
 
 
c32102b
 
 
2798900
 
 
 
 
 
 
 
 
c32102b
2798900
 
 
 
 
 
c32102b
ef02f17
 
 
2798900
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""
Rhodawk AI — Namespaced Job Queue (SQLite backed, v2 — Apr 2026)
================================================================
Replaces the original per-file JSON store with a single SQLite database so
that concurrent writers (web UI + nightmode + OSS-Guardian) never race on
``open(...).replace(...)``.  All previous JSON files in ``/data/jobs/``
are imported on first start, then ignored.

The public API is intentionally identical to the v1 module so callers
do not need to change.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
from enum import Enum
from pathlib import Path
from typing import Optional

LOG = logging.getLogger("job_queue")

QUEUE_DIR = "/data/jobs"               # legacy JSON dir, kept for migration
DB_PATH   = os.getenv("JOB_QUEUE_DB", "/data/jobs.sqlite")
_LOCK = threading.Lock()


class JobStatus(Enum):
    PENDING = "PENDING"
    RUNNING = "RUNNING"
    SAST_BLOCKED = "SAST_BLOCKED"
    DONE = "DONE"
    FAILED = "FAILED"


def _job_id(tenant_id: str, repo: str, test_path: str) -> str:
    raw = f"{tenant_id}::{repo}::{test_path}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]


def _connect() -> sqlite3.Connection:
    Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB_PATH, isolation_level=None, timeout=30.0)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=NORMAL")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS jobs (
            job_id              TEXT PRIMARY KEY,
            tenant_id           TEXT NOT NULL,
            repo                TEXT NOT NULL,
            test_path           TEXT NOT NULL,
            status              TEXT NOT NULL,
            detail              TEXT NOT NULL DEFAULT '',
            pr_url              TEXT,
            sast_findings_count INTEGER,
            model_version       TEXT,
            prompt_hash         TEXT,
            created_at          TEXT NOT NULL,
            updated_at          TEXT NOT NULL
        )
    """)
    conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_tenant ON jobs(tenant_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, updated_at)")
    return conn


def _migrate_legacy_json() -> int:
    """One-shot import of any leftover ``/data/jobs/*.json`` files."""
    if not os.path.isdir(QUEUE_DIR):
        return 0
    imported = 0
    for fname in os.listdir(QUEUE_DIR):
        if not fname.endswith(".json"):
            continue
        path = os.path.join(QUEUE_DIR, fname)
        try:
            with open(path) as f:
                j = json.load(f)
            with _connect() as c:
                c.execute("""
                    INSERT OR IGNORE INTO jobs
                    (job_id, tenant_id, repo, test_path, status, detail,
                     pr_url, sast_findings_count, model_version, prompt_hash,
                     created_at, updated_at)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, (
                    j.get("job_id") or _job_id(j.get("tenant_id", ""),
                                               j.get("repo", ""),
                                               j.get("test_path", "")),
                    j.get("tenant_id", ""),
                    j.get("repo", ""),
                    j.get("test_path", ""),
                    j.get("status", "PENDING"),
                    j.get("detail", "") or "",
                    j.get("pr_url"),
                    j.get("sast_findings_count"),
                    j.get("model_version"),
                    j.get("prompt_hash"),
                    j.get("created_at") or j.get("updated_at") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                    j.get("updated_at") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                ))
            os.rename(path, path + ".imported")
            imported += 1
        except Exception as exc:  # noqa: BLE001
            LOG.warning("migrate %s failed: %s", path, exc)
    return imported


_MIGRATED = False


def _ensure_migrated() -> None:
    global _MIGRATED
    if _MIGRATED:
        return
    with _LOCK:
        if _MIGRATED:
            return
        _migrate_legacy_json()
        _MIGRATED = True


def upsert_job(
    tenant_id: str,
    repo: str,
    test_path: str,
    status: JobStatus,
    detail: str = "",
    pr_url: Optional[str] = None,
    sast_findings: Optional[list] = None,
    model_version: Optional[str] = None,
    prompt_hash: Optional[str] = None,
) -> str:
    _ensure_migrated()
    jid = _job_id(tenant_id, repo, test_path)
    now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    findings_count = len(sast_findings) if sast_findings is not None else None
    with _LOCK, _connect() as c:
        existing = c.execute(
            "SELECT created_at FROM jobs WHERE job_id=?", (jid,)
        ).fetchone()
        created = existing[0] if existing else now
        c.execute("""
            INSERT INTO jobs (job_id, tenant_id, repo, test_path, status,
                              detail, pr_url, sast_findings_count,
                              model_version, prompt_hash, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(job_id) DO UPDATE SET
                status              = excluded.status,
                detail              = excluded.detail,
                pr_url              = COALESCE(excluded.pr_url, jobs.pr_url),
                sast_findings_count = COALESCE(excluded.sast_findings_count, jobs.sast_findings_count),
                model_version       = COALESCE(excluded.model_version, jobs.model_version),
                prompt_hash         = COALESCE(excluded.prompt_hash, jobs.prompt_hash),
                updated_at          = excluded.updated_at
        """, (jid, tenant_id, repo, test_path, status.value, detail or "",
              pr_url, findings_count, model_version, prompt_hash, created, now))
    return jid


def get_job(tenant_id: str, repo: str, test_path: str) -> Optional[dict]:
    _ensure_migrated()
    jid = _job_id(tenant_id, repo, test_path)
    with _connect() as c:
        c.row_factory = sqlite3.Row
        row = c.execute("SELECT * FROM jobs WHERE job_id=?", (jid,)).fetchone()
    return dict(row) if row else None


def get_job_status_enum(tenant_id: str, repo: str, test_path: str) -> Optional[JobStatus]:
    job = get_job(tenant_id, repo, test_path)
    if not job:
        return None
    try:
        return JobStatus(job["status"])
    except ValueError:
        return None


def list_all_jobs() -> list[dict]:
    _ensure_migrated()
    with _connect() as c:
        c.row_factory = sqlite3.Row
        rows = c.execute(
            "SELECT * FROM jobs ORDER BY updated_at DESC"
        ).fetchall()
    return [dict(r) for r in rows]


def get_metrics() -> dict:
    _ensure_migrated()
    with _connect() as c:
        total = c.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
        by_status = dict(c.execute(
            "SELECT status, COUNT(*) FROM jobs GROUP BY status"
        ).fetchall())
        prs = c.execute(
            "SELECT COUNT(*) FROM jobs WHERE pr_url IS NOT NULL AND pr_url != ''"
        ).fetchone()[0]
    return {
        "total":        total,
        "done":         by_status.get("DONE", 0),
        "failed":       by_status.get("FAILED", 0),
        "running":      by_status.get("RUNNING", 0),
        "sast_blocked": by_status.get("SAST_BLOCKED", 0),
        "prs_created":  prs,
    }


def prune_done_jobs(max_age_hours: int = 72) -> int:
    """Remove DONE/FAILED jobs older than ``max_age_hours``."""
    _ensure_migrated()
    cutoff_ts = time.time() - (max_age_hours * 3600)
    cutoff_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(cutoff_ts))
    with _LOCK, _connect() as c:
        cur = c.execute("""
            DELETE FROM jobs
            WHERE status IN ('DONE', 'FAILED')
              AND updated_at < ?
        """, (cutoff_iso,))
        return cur.rowcount or 0