vibe-coding-qa 0.5.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 (58) hide show
  1. dashboard/__init__.py +10 -0
  2. dashboard/backend/__init__.py +11 -0
  3. dashboard/backend/__main__.py +6 -0
  4. dashboard/backend/ai/__init__.py +6 -0
  5. dashboard/backend/ai/provider.py +102 -0
  6. dashboard/backend/ai/service.py +757 -0
  7. dashboard/backend/api/__init__.py +5 -0
  8. dashboard/backend/api/ai.py +142 -0
  9. dashboard/backend/api/dashboard.py +34 -0
  10. dashboard/backend/api/deps.py +51 -0
  11. dashboard/backend/api/findings.py +102 -0
  12. dashboard/backend/api/intel.py +41 -0
  13. dashboard/backend/api/repositories.py +62 -0
  14. dashboard/backend/api/scans.py +168 -0
  15. dashboard/backend/api/system.py +69 -0
  16. dashboard/backend/config.py +180 -0
  17. dashboard/backend/main.py +183 -0
  18. dashboard/backend/persistence/__init__.py +5 -0
  19. dashboard/backend/persistence/database.py +278 -0
  20. dashboard/backend/persistence/store.py +600 -0
  21. dashboard/backend/profiles.py +141 -0
  22. dashboard/backend/schemas.py +452 -0
  23. dashboard/backend/security.py +158 -0
  24. dashboard/backend/services/__init__.py +7 -0
  25. dashboard/backend/services/dashboard.py +172 -0
  26. dashboard/backend/services/intel.py +341 -0
  27. dashboard/backend/services/paths.py +38 -0
  28. dashboard/backend/services/repositories.py +123 -0
  29. dashboard/backend/services/scans.py +328 -0
  30. dashboard/backend/services/settings.py +49 -0
  31. dashboard/frontend/dist/assets/index-COC-CdqD.js +492 -0
  32. dashboard/frontend/dist/assets/index-hWDLRaLr.css +1 -0
  33. dashboard/frontend/dist/index.html +29 -0
  34. dashboard/frontend/dist/phone-mascots.gif +0 -0
  35. dashboard/launcher.py +48 -0
  36. vibe_coding_qa/__init__.py +140 -0
  37. vibe_coding_qa/__main__.py +5 -0
  38. vibe_coding_qa/ai_review.py +165 -0
  39. vibe_coding_qa/cli.py +157 -0
  40. vibe_coding_qa/engine.py +860 -0
  41. vibe_coding_qa/fixes.py +222 -0
  42. vibe_coding_qa/gates.py +300 -0
  43. vibe_coding_qa/profile.py +471 -0
  44. vibe_coding_qa/repo.py +188 -0
  45. vibe_coding_qa/reporters/__init__.py +253 -0
  46. vibe_coding_qa/scanners/__init__.py +195 -0
  47. vibe_coding_qa/scanners/compliance.py +167 -0
  48. vibe_coding_qa/scanners/database.py +316 -0
  49. vibe_coding_qa/scanners/dependencies.py +595 -0
  50. vibe_coding_qa/scanners/infrastructure.py +125 -0
  51. vibe_coding_qa/scanners/performance.py +84 -0
  52. vibe_coding_qa/scanners/quality.py +129 -0
  53. vibe_coding_qa/scanners/security.py +521 -0
  54. vibe_coding_qa-0.5.0.dist-info/METADATA +323 -0
  55. vibe_coding_qa-0.5.0.dist-info/RECORD +58 -0
  56. vibe_coding_qa-0.5.0.dist-info/WHEEL +5 -0
  57. vibe_coding_qa-0.5.0.dist-info/entry_points.txt +3 -0
  58. vibe_coding_qa-0.5.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,5 @@
1
+ """FastAPI routers for the dashboard API."""
2
+
3
+ from . import ai, dashboard, deps, findings, intel, repositories, scans, system
4
+
5
+ __all__ = ["ai", "dashboard", "deps", "findings", "intel", "repositories", "scans", "system"]
@@ -0,0 +1,142 @@
1
+ """AI routes.
2
+
3
+ These endpoints are advisory only. They never influence the release gate, never
4
+ apply a patch without an explicit, confirmed approval call, and never return the
5
+ provider API key. When no provider is configured they answer with
6
+ ``configured = false`` and an actionable message instead of inventing content.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated
12
+
13
+ from fastapi import APIRouter, Depends
14
+
15
+ from ..schemas import (
16
+ AIChatRequest,
17
+ AIChatResponse,
18
+ AIConversationOut,
19
+ AIReviewOut,
20
+ AIReviewRequest,
21
+ ApproveRequest,
22
+ ConversationOut,
23
+ GenericMessage,
24
+ RemediationOut,
25
+ RemediationProposeRequest,
26
+ SummaryOut,
27
+ SummaryRequest,
28
+ TestGenerateOut,
29
+ ThreatModelOut,
30
+ ThreatModelRequest,
31
+ )
32
+ from .deps import Services, get_services
33
+
34
+ router = APIRouter(tags=["ai"])
35
+
36
+ ServicesDep = Annotated[Services, Depends(get_services)]
37
+
38
+
39
+ # ------------------------------------------------------------------- assistant
40
+ @router.post("/api/ai/chat", response_model=AIChatResponse)
41
+ def chat(payload: AIChatRequest, services: ServicesDep) -> dict:
42
+ return services.ai.chat(
43
+ message=payload.message,
44
+ repository_id=payload.repository_id,
45
+ conversation_id=payload.conversation_id,
46
+ )
47
+
48
+
49
+ @router.get("/api/ai/conversations", response_model=list[ConversationOut])
50
+ def list_conversations(services: ServicesDep, repository_id: int | None = None) -> list[dict]:
51
+ conversations = services.store.list_conversations(repository_id)
52
+ return [
53
+ {**item, "message_count": len(services.store.list_messages(item["id"]))}
54
+ for item in conversations
55
+ ]
56
+
57
+
58
+ @router.get("/api/ai/conversations/{conversation_id}", response_model=AIConversationOut)
59
+ def get_conversation(conversation_id: int, services: ServicesDep) -> dict:
60
+ conversation = services.store.get_conversation(conversation_id)
61
+ if conversation is None:
62
+ raise KeyError(f"Conversation {conversation_id} was not found.")
63
+ return conversation
64
+
65
+
66
+ @router.delete("/api/ai/conversations/{conversation_id}", response_model=GenericMessage)
67
+ def delete_conversation(conversation_id: int, services: ServicesDep) -> dict:
68
+ if services.store.get_conversation(conversation_id) is None:
69
+ raise KeyError(f"Conversation {conversation_id} was not found.")
70
+ services.store.delete_conversation(conversation_id)
71
+ return {"detail": "Conversation deleted."}
72
+
73
+
74
+ # -------------------------------------------------------------- security analyst
75
+ @router.post("/api/ai/findings/{finding_id}/review", response_model=AIReviewOut)
76
+ def review_finding(finding_id: int, payload: AIReviewRequest, services: ServicesDep) -> dict:
77
+ return services.ai.review_finding(finding_id, payload.kind)
78
+
79
+
80
+ @router.post("/api/ai/findings/{finding_id}/test", response_model=TestGenerateOut)
81
+ def generate_test(finding_id: int, services: ServicesDep) -> dict:
82
+ """Draft a regression test. It is returned as text and never written to disk."""
83
+ return services.ai.generate_test(finding_id)
84
+
85
+
86
+ # ----------------------------------------------------------------- remediation
87
+ @router.post("/api/ai/remediations", response_model=RemediationOut, status_code=201)
88
+ def propose_remediation(payload: RemediationProposeRequest, services: ServicesDep) -> dict:
89
+ """Generate a patch proposal. Nothing is applied to the working tree."""
90
+ return services.ai.propose_remediation(payload.finding_id)
91
+
92
+
93
+ @router.get("/api/ai/remediations", response_model=list[RemediationOut])
94
+ def list_remediations(
95
+ services: ServicesDep,
96
+ repository_id: int | None = None,
97
+ finding_id: int | None = None,
98
+ ) -> list[dict]:
99
+ return services.store.list_proposals(repository_id=repository_id, finding_id=finding_id)
100
+
101
+
102
+ @router.get("/api/ai/remediations/{proposal_id}", response_model=RemediationOut)
103
+ def get_remediation(proposal_id: int, services: ServicesDep) -> dict:
104
+ proposal = services.store.get_proposal(proposal_id)
105
+ if proposal is None:
106
+ raise KeyError(f"Proposal {proposal_id} was not found.")
107
+ return proposal
108
+
109
+
110
+ @router.post("/api/ai/remediations/{proposal_id}/approve", response_model=RemediationOut)
111
+ def approve_remediation(
112
+ proposal_id: int, payload: ApproveRequest, services: ServicesDep
113
+ ) -> dict:
114
+ """Apply a proposal to the working tree.
115
+
116
+ Requires ``confirm = true``; a backup of the original file is written first
117
+ so the change can be rolled back.
118
+ """
119
+ return services.ai.approve_remediation(
120
+ proposal_id,
121
+ confirm=payload.confirm,
122
+ run_tests=payload.run_tests,
123
+ explicit_permission=payload.explicit_permission,
124
+ )
125
+
126
+
127
+ @router.post("/api/ai/remediations/{proposal_id}/rollback", response_model=RemediationOut)
128
+ def rollback_remediation(proposal_id: int, services: ServicesDep) -> dict:
129
+ return services.ai.rollback_remediation(proposal_id)
130
+
131
+
132
+ # ---------------------------------------------------------------------- reports
133
+ @router.post("/api/ai/summary", response_model=SummaryOut)
134
+ def executive_summary(payload: SummaryRequest, services: ServicesDep) -> dict:
135
+ return services.ai.executive_summary(
136
+ repository_id=payload.repository_id, scan_id=payload.scan_id
137
+ )
138
+
139
+
140
+ @router.post("/api/ai/threat-model", response_model=ThreatModelOut)
141
+ def threat_model(payload: ThreatModelRequest, services: ServicesDep) -> dict:
142
+ return services.ai.threat_model(payload.repository_id)
@@ -0,0 +1,34 @@
1
+ """Dashboard aggregation routes (overview cards, charts and the activity feed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+
9
+ from ..schemas import ActivityOut, OverviewOut
10
+ from .deps import Services, get_services
11
+
12
+ router = APIRouter(tags=["dashboard"])
13
+
14
+ ServicesDep = Annotated[Services, Depends(get_services)]
15
+
16
+
17
+ @router.get("/api/dashboard/overview", response_model=OverviewOut)
18
+ def overview(
19
+ services: ServicesDep,
20
+ repository_id: int | None = None,
21
+ ) -> dict:
22
+ """Overview metrics derived from persisted scans only.
23
+
24
+ ``has_data`` is false and every series is empty until a scan has actually
25
+ completed — the dashboard never renders invented numbers.
26
+ """
27
+ if repository_id is not None:
28
+ services.repositories.require(repository_id)
29
+ return services.dashboard.overview(repository_id)
30
+
31
+
32
+ @router.get("/api/dashboard/activity", response_model=ActivityOut)
33
+ def activity(services: ServicesDep, limit: int = Query(default=30, ge=1, le=100)) -> dict:
34
+ return services.dashboard.activity(limit)
@@ -0,0 +1,51 @@
1
+ """Shared service container exposed to the API through a FastAPI dependency.
2
+
3
+ The container is created once per application instance and stored on
4
+ ``app.state``, so every route sees the same SQLite store, scan runner and AI
5
+ service. This keeps the routes thin and makes the wiring testable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from fastapi import Request
13
+
14
+ from ..ai.service import AIService
15
+ from ..config import Settings, get_settings
16
+ from ..persistence.database import Database
17
+ from ..persistence.store import Store
18
+ from ..services.dashboard import DashboardService
19
+ from ..services.repositories import RepositoryService
20
+ from ..services.scans import ScanRunner
21
+
22
+
23
+ @dataclass
24
+ class Services:
25
+ settings: Settings
26
+ database: Database
27
+ store: Store
28
+ repositories: RepositoryService
29
+ scans: ScanRunner
30
+ dashboard: DashboardService
31
+ ai: AIService
32
+
33
+
34
+ def build_services(settings: Settings | None = None) -> Services:
35
+ resolved = settings or get_settings()
36
+ database = Database(resolved.db_path)
37
+ store = Store(database)
38
+ repositories = RepositoryService(store, resolved)
39
+ return Services(
40
+ settings=resolved,
41
+ database=database,
42
+ store=store,
43
+ repositories=repositories,
44
+ scans=ScanRunner(store, resolved, repositories),
45
+ dashboard=DashboardService(store, resolved),
46
+ ai=AIService(store, resolved),
47
+ )
48
+
49
+
50
+ def get_services(request: Request) -> Services:
51
+ return request.app.state.services
@@ -0,0 +1,102 @@
1
+ """Finding routes: filter, inspect with real source evidence, and triage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+
9
+ from vibe_coding_qa.scanners import redact_secrets
10
+
11
+ from ..schemas import FindingDetailOut, FindingListOut, FindingOut, FindingStatusUpdate
12
+ from ..services.paths import repository_dir
13
+ from ..security import ValidationError, resolve_repo_file
14
+ from .deps import Services, get_services
15
+
16
+ router = APIRouter(tags=["findings"])
17
+
18
+ ServicesDep = Annotated[Services, Depends(get_services)]
19
+
20
+ _EVIDENCE_RADIUS = 12
21
+
22
+
23
+ @router.get("/api/findings", response_model=FindingListOut)
24
+ def list_findings(
25
+ services: ServicesDep,
26
+ repository_id: int | None = None,
27
+ scan_id: int | None = None,
28
+ severity: list[str] | None = Query(default=None),
29
+ module: list[str] | None = Query(default=None),
30
+ status: list[str] | None = Query(default=None),
31
+ search: str | None = None,
32
+ limit: int | None = Query(default=None, ge=1, le=200),
33
+ offset: int = Query(default=0, ge=0),
34
+ ) -> dict:
35
+ """List findings.
36
+
37
+ Without an explicit ``scan_id`` the store restricts results to the newest
38
+ completed scan per repository, so the UI shows the current state instead of
39
+ historical duplicates.
40
+ """
41
+ page_size = limit or services.settings.finding_page_size
42
+ page_size = min(page_size, services.settings.max_page_size)
43
+ items, total = services.store.list_findings(
44
+ repository_id=repository_id,
45
+ scan_id=scan_id,
46
+ severity=severity,
47
+ module=module,
48
+ status=status,
49
+ search=search,
50
+ limit=page_size,
51
+ offset=offset,
52
+ )
53
+ return {"items": items, "total": total, "limit": page_size, "offset": offset}
54
+
55
+
56
+ @router.get("/api/findings/{finding_id}", response_model=FindingDetailOut)
57
+ def get_finding(finding_id: int, services: ServicesDep) -> dict:
58
+ finding = services.store.get_finding(finding_id)
59
+ if finding is None:
60
+ raise KeyError(f"Finding {finding_id} was not found.")
61
+
62
+ detail = dict(finding)
63
+ detail["evidence"] = _source_evidence(services, finding)
64
+ detail["history"] = services.store.finding_history(finding["fingerprint"], finding["repository_id"])
65
+ detail["related"] = services.store.findings_in_file(
66
+ finding["repository_id"], finding["file"], exclude_id=finding_id
67
+ )
68
+ review = services.store.latest_ai_review(finding_id)
69
+ detail["latest_ai_review"] = review
70
+ return detail
71
+
72
+
73
+ @router.patch("/api/findings/{finding_id}", response_model=FindingOut)
74
+ def update_finding_status(
75
+ finding_id: int, payload: FindingStatusUpdate, services: ServicesDep
76
+ ) -> dict:
77
+ """Triage a single finding. The status is local to the dashboard."""
78
+ if services.store.get_finding(finding_id) is None:
79
+ raise KeyError(f"Finding {finding_id} was not found.")
80
+ return services.store.set_finding_status(finding_id, payload.status)
81
+
82
+
83
+ def _source_evidence(services: Services, finding: dict, radius: int = _EVIDENCE_RADIUS) -> list[str]:
84
+ """Numbered, redacted lines around the finding, read from the working tree."""
85
+ repository = services.store.get_repository(finding["repository_id"])
86
+ if repository is None:
87
+ return []
88
+ root = repository_dir(repository)
89
+ if root is None:
90
+ return []
91
+ try:
92
+ target = resolve_repo_file(root, finding["file"])
93
+ except (OSError, RuntimeError, ValueError, ValidationError):
94
+ return []
95
+ try:
96
+ lines = target.read_text(encoding="utf-8", errors="ignore").splitlines()
97
+ except OSError:
98
+ return []
99
+ index = max(0, int(finding["line"]) - 1)
100
+ start = max(0, index - radius)
101
+ end = min(len(lines), index + radius + 1)
102
+ return [redact_secrets(f"{number + 1:>5}: {lines[number]}") for number in range(start, end)]
@@ -0,0 +1,41 @@
1
+ """Architecture and dependency intelligence routes.
2
+
3
+ Both read only static evidence (the repository profile, resolved lockfiles and
4
+ the persisted findings of the latest scan). When the evidence is missing the
5
+ response says so rather than filling the gap with guesswork.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated
11
+
12
+ from fastapi import APIRouter, Depends, Query
13
+
14
+ from ..schemas import ArchitectureOut, DependenciesOut
15
+ from ..services.intel import build_architecture, build_dependencies
16
+ from .deps import Services, get_services
17
+
18
+ router = APIRouter(tags=["intelligence"])
19
+
20
+ ServicesDep = Annotated[Services, Depends(get_services)]
21
+
22
+
23
+ @router.get("/api/architecture/{repository_id}", response_model=ArchitectureOut)
24
+ def architecture(repository_id: int, services: ServicesDep) -> dict:
25
+ repository = services.repositories.require(repository_id)
26
+ return build_architecture(services.store, repository)
27
+
28
+
29
+ @router.get("/api/dependencies/{repository_id}", response_model=DependenciesOut)
30
+ def dependencies(
31
+ repository_id: int,
32
+ services: ServicesDep,
33
+ osv: bool = Query(default=False, description="Query the OSV advisory database (network access)"),
34
+ ) -> dict:
35
+ """Dependencies with resolved versions.
36
+
37
+ OSV lookups are opt-in: a GET never performs network calls unless the caller
38
+ asks for them explicitly.
39
+ """
40
+ repository = services.repositories.require(repository_id)
41
+ return build_dependencies(services.store, repository, osv=osv)
@@ -0,0 +1,62 @@
1
+ """Repository routes: register, inspect, refresh and remove repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+
9
+ from ..schemas import GenericMessage, RepositoryCreate, RepositoryOut, ScanListOut
10
+ from .deps import Services, get_services
11
+
12
+ router = APIRouter(tags=["repositories"])
13
+
14
+ ServicesDep = Annotated[Services, Depends(get_services)]
15
+
16
+
17
+ @router.get("/api/repositories", response_model=list[RepositoryOut])
18
+ def list_repositories(services: ServicesDep) -> list[dict]:
19
+ return services.repositories.list()
20
+
21
+
22
+ @router.post("/api/repositories", response_model=RepositoryOut, status_code=201)
23
+ def create_repository(payload: RepositoryCreate, services: ServicesDep) -> dict:
24
+ """Register a local directory or a remote git source.
25
+
26
+ Nothing is cloned here: a remote repository is fetched the first time it is
27
+ scanned, so registration never performs a surprise network operation.
28
+ """
29
+ return services.repositories.register(
30
+ source=payload.source, source_type=payload.source_type, name=payload.name
31
+ )
32
+
33
+
34
+ @router.get("/api/repositories/{repository_id}", response_model=RepositoryOut)
35
+ def get_repository(repository_id: int, services: ServicesDep) -> dict:
36
+ return services.repositories.require(repository_id)
37
+
38
+
39
+ @router.post("/api/repositories/{repository_id}/refresh", response_model=RepositoryOut)
40
+ def refresh_repository(repository_id: int, services: ServicesDep) -> dict:
41
+ return services.repositories.refresh(repository_id)
42
+
43
+
44
+ @router.delete("/api/repositories/{repository_id}", response_model=GenericMessage)
45
+ def delete_repository(repository_id: int, services: ServicesDep) -> dict:
46
+ services.repositories.require(repository_id)
47
+ services.repositories.delete(repository_id)
48
+ return {"detail": "Repository and its scan history were deleted."}
49
+
50
+
51
+ @router.get("/api/repositories/{repository_id}/scans", response_model=ScanListOut)
52
+ def repository_scans(
53
+ repository_id: int,
54
+ services: ServicesDep,
55
+ limit: int = Query(default=50, ge=1, le=200),
56
+ offset: int = Query(default=0, ge=0),
57
+ ) -> dict:
58
+ services.repositories.require(repository_id)
59
+ items, total = services.store.list_scans(
60
+ repository_id=repository_id, limit=limit, offset=offset
61
+ )
62
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
@@ -0,0 +1,168 @@
1
+ """Scan routes: create, list, inspect, cancel, diff, and stream live events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Annotated
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
10
+ from fastapi.responses import StreamingResponse
11
+
12
+ from ..profiles import get_profile
13
+ from ..schemas import ScanCreate, ScanEventOut, ScanListOut, ScanOut
14
+ from ..security import ValidationError
15
+ from ..services.intel import compare_scans
16
+ from ..services.settings import scan_settings
17
+ from .deps import Services, get_services
18
+
19
+ router = APIRouter(tags=["scans"])
20
+
21
+ ServicesDep = Annotated[Services, Depends(get_services)]
22
+
23
+ _TERMINAL = {"completed", "failed", "cancelled"}
24
+ _POLL_SECONDS = 0.5
25
+ _MAX_STREAM_SECONDS = 60 * 60
26
+
27
+
28
+ # NOTE: this must be declared before ``/api/scans/{scan_id}`` so that "compare"
29
+ # is not parsed as a scan id.
30
+ @router.get("/api/scans/compare")
31
+ def diff_scans(
32
+ services: ServicesDep,
33
+ base: int = Query(description="Baseline scan id"),
34
+ head: int = Query(description="Scan id to compare against the baseline"),
35
+ ) -> dict:
36
+ if base == head:
37
+ raise ValidationError("Pick two different scans to compare.")
38
+ return compare_scans(services.store, base, head)
39
+
40
+
41
+ @router.post("/api/scans", response_model=ScanOut, status_code=202)
42
+ def create_scan(payload: ScanCreate, services: ServicesDep) -> dict:
43
+ """Queue a scan. It runs in the bounded background pool, not in this request."""
44
+ repository = services.repositories.require(payload.repository_id)
45
+ defaults = scan_settings(services.store, services.settings)
46
+
47
+ profile_key = (payload.profile or defaults["default_scan_profile"]).strip()
48
+ try:
49
+ get_profile(profile_key)
50
+ except KeyError as exc:
51
+ raise ValidationError(f"Unknown scan profile: {profile_key}") from exc
52
+
53
+ options = payload.options.model_dump(exclude_none=True)
54
+ try:
55
+ return services.scans.create(repository=repository, profile_key=profile_key, options=options)
56
+ except RuntimeError as exc: # the queue is bounded on purpose
57
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
58
+
59
+
60
+ @router.get("/api/scans", response_model=ScanListOut)
61
+ def list_scans(
62
+ services: ServicesDep,
63
+ repository_id: int | None = None,
64
+ status: str | None = Query(default=None, pattern="^(queued|running|completed|failed|cancelled)$"),
65
+ limit: int = Query(default=50, ge=1, le=200),
66
+ offset: int = Query(default=0, ge=0),
67
+ ) -> dict:
68
+ items, total = services.store.list_scans(
69
+ repository_id=repository_id, status=status, limit=limit, offset=offset
70
+ )
71
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
72
+
73
+
74
+ @router.get("/api/scans/{scan_id}", response_model=ScanOut)
75
+ def get_scan(scan_id: int, services: ServicesDep) -> dict:
76
+ scan = services.store.get_scan(scan_id)
77
+ if scan is None:
78
+ raise KeyError(f"Scan {scan_id} was not found.")
79
+ return scan
80
+
81
+
82
+ @router.post("/api/scans/{scan_id}/cancel", response_model=ScanOut)
83
+ def cancel_scan(scan_id: int, services: ServicesDep) -> dict:
84
+ """Cancel a scan that has not started yet.
85
+
86
+ A scan already executing inside the sandbox is left to finish: interrupting
87
+ it mid-run would risk leaving the engine in an inconsistent state.
88
+ """
89
+ scan = services.scans.cancel(scan_id)
90
+ if scan is None:
91
+ raise KeyError(f"Scan {scan_id} was not found.")
92
+ return scan
93
+
94
+
95
+ @router.get("/api/scans/{scan_id}/events.json", response_model=list[ScanEventOut])
96
+ def scan_events_snapshot(
97
+ scan_id: int,
98
+ services: ServicesDep,
99
+ after: int = Query(default=0, ge=0),
100
+ ) -> list[dict]:
101
+ """Every persisted event for a scan (used for polling and by the tests)."""
102
+ if services.store.get_scan(scan_id) is None:
103
+ raise KeyError(f"Scan {scan_id} was not found.")
104
+ return services.store.list_events(scan_id, after_id=after)
105
+
106
+
107
+ @router.get("/api/scans/{scan_id}/events")
108
+ async def scan_events_stream(scan_id: int, request: Request, services: ServicesDep, after: int = 0):
109
+ """Server-Sent Events for a scan.
110
+
111
+ Only events the engine actually emitted are sent: the dashboard never
112
+ fabricates progress. The stream ends once the scan reaches a terminal state
113
+ and every event has been delivered.
114
+ """
115
+ if services.store.get_scan(scan_id) is None:
116
+ raise KeyError(f"Scan {scan_id} was not found.")
117
+
118
+ return StreamingResponse(
119
+ _event_stream(request, services, scan_id, after),
120
+ media_type="text/event-stream",
121
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
122
+ )
123
+
124
+
125
+ async def _event_stream(request: Request, services: Services, scan_id: int, after: int):
126
+ last_id = max(0, after)
127
+ elapsed = 0.0
128
+ yield _sse("open", {"scan_id": scan_id})
129
+ while True:
130
+ if await request.is_disconnected():
131
+ return
132
+
133
+ events = services.store.list_events(scan_id, after_id=last_id)
134
+ if events:
135
+ for event in events:
136
+ last_id = event["id"]
137
+ yield _sse("scan-event", {
138
+ "id": event["id"],
139
+ "scan_id": event["scan_id"],
140
+ "ts": event["ts"],
141
+ "stage": event["stage"],
142
+ "status": event.get("status"),
143
+ "message": event.get("message"),
144
+ "data": event.get("data") or {},
145
+ })
146
+ continue
147
+
148
+ scan = services.store.get_scan(scan_id)
149
+ status = (scan or {}).get("status")
150
+ if status in _TERMINAL:
151
+ yield _sse("end", {"scan_id": scan_id, "status": status})
152
+ return
153
+ if status is None:
154
+ yield _sse("end", {"scan_id": scan_id, "status": "missing"})
155
+ return
156
+
157
+ elapsed += _POLL_SECONDS
158
+ if elapsed >= _MAX_STREAM_SECONDS:
159
+ yield _sse("end", {"scan_id": scan_id, "status": status, "reason": "stream-timeout"})
160
+ return
161
+
162
+ # Comment frame keeps proxies and browsers from closing the connection.
163
+ yield ": keep-alive\n\n"
164
+ await asyncio.sleep(_POLL_SECONDS)
165
+
166
+
167
+ def _sse(event: str, payload: dict) -> str:
168
+ return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -0,0 +1,69 @@
1
+ """System routes: health, scan profiles, and operator settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends
8
+
9
+ from .. import __version__
10
+ from ..profiles import list_profiles
11
+ from ..schemas import (
12
+ AIConnectionTestOut,
13
+ AISettingsIn,
14
+ AISettingsOut,
15
+ HealthOut,
16
+ ScanSettingsIn,
17
+ ScanSettingsOut,
18
+ )
19
+ from ..services.settings import scan_settings, update_scan_settings
20
+ from .deps import Services, get_services
21
+
22
+ router = APIRouter(tags=["system"])
23
+
24
+ ServicesDep = Annotated[Services, Depends(get_services)]
25
+
26
+
27
+ @router.get("/api/health", response_model=HealthOut)
28
+ def health(services: ServicesDep) -> dict:
29
+ """Liveness plus the facts the UI needs to render its header."""
30
+ return {
31
+ "status": "ok",
32
+ "version": __version__,
33
+ "database": services.settings.db_path.name,
34
+ "ai_configured": services.ai.config().enabled,
35
+ "repositories": services.store.count_repositories(),
36
+ "scans": services.store.count_scans(),
37
+ }
38
+
39
+
40
+ @router.get("/api/profiles")
41
+ def profiles() -> list[dict]:
42
+ """The available scan profiles and exactly what each one changes."""
43
+ return list_profiles()
44
+
45
+
46
+ @router.get("/api/settings/scan", response_model=ScanSettingsOut)
47
+ def read_scan_settings(services: ServicesDep) -> dict:
48
+ return scan_settings(services.store, services.settings)
49
+
50
+
51
+ @router.put("/api/settings/scan", response_model=ScanSettingsOut)
52
+ def write_scan_settings(payload: ScanSettingsIn, services: ServicesDep) -> dict:
53
+ return update_scan_settings(services.store, payload)
54
+
55
+
56
+ @router.get("/api/settings/ai", response_model=AISettingsOut)
57
+ def read_ai_settings(services: ServicesDep) -> dict:
58
+ """The AI configuration. The API key itself is never returned."""
59
+ return services.ai.settings_view()
60
+
61
+
62
+ @router.put("/api/settings/ai", response_model=AISettingsOut)
63
+ def write_ai_settings(payload: AISettingsIn, services: ServicesDep) -> dict:
64
+ return services.ai.update_settings(payload)
65
+
66
+
67
+ @router.post("/api/settings/ai/test", response_model=AIConnectionTestOut)
68
+ def test_ai_connection(services: ServicesDep) -> dict:
69
+ return services.ai.test_connection()