devtorch-core 3.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2012 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sprint 13 – Browser-based Org Dashboard REST API.
|
|
3
|
+
|
|
4
|
+
Serves governance state from a local `.GCC/` directory over HTTP.
|
|
5
|
+
This is a read-only surface: it never writes to `.GCC/`.
|
|
6
|
+
|
|
7
|
+
Endpoints
|
|
8
|
+
---------
|
|
9
|
+
GET /api/v1/dashboard → DashboardData (full snapshot)
|
|
10
|
+
GET /api/v1/branches → list[BranchSummary]
|
|
11
|
+
GET /api/v1/sensitivities → list[SensitivityEvent] (query: limit=50)
|
|
12
|
+
|
|
13
|
+
Usage
|
|
14
|
+
-----
|
|
15
|
+
from devtorch_core.dashboard_api import run_dashboard_api
|
|
16
|
+
run_dashboard_api(port=8766, host="127.0.0.1", gcc_repo="/path/to/project")
|
|
17
|
+
|
|
18
|
+
Or run directly:
|
|
19
|
+
python -m devtorch_core.dashboard_api --port 8766 --repo /path/to/project
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import json
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Dict, List, Optional
|
|
32
|
+
|
|
33
|
+
from devtorch_core.projects import ProjectRegistry
|
|
34
|
+
from devtorch_core.metrics.aggregate import (
|
|
35
|
+
aggregate_tokens,
|
|
36
|
+
aggregate_speedup,
|
|
37
|
+
)
|
|
38
|
+
from devtorch_core.metrics.shadow_ai import detect_shadow_ai
|
|
39
|
+
from devtorch_core.metrics.delivery_time import compute_delivery_time_summary
|
|
40
|
+
from devtorch_core.metrics.sprint_writer import (
|
|
41
|
+
compute_sprint_metrics as _compute_sprint_metrics,
|
|
42
|
+
read_sprint_metrics as _read_sprint_metrics,
|
|
43
|
+
)
|
|
44
|
+
from devtorch_core.broadcast import EventBroadcaster, GCCWatcher
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from fastapi import FastAPI, HTTPException, Query, Response
|
|
48
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
49
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
|
50
|
+
from pydantic import BaseModel
|
|
51
|
+
import uvicorn
|
|
52
|
+
_FASTAPI_AVAILABLE = True
|
|
53
|
+
except ImportError: # pragma: no cover
|
|
54
|
+
_FASTAPI_AVAILABLE = False
|
|
55
|
+
|
|
56
|
+
class BaseModel: # type: ignore[no-redef]
|
|
57
|
+
"""Fallback when FastAPI/Pydantic are not installed."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, **data: Any) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Pydantic response models (mirrors src/api/types.ts)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
class GCCStatusModel(BaseModel):
|
|
68
|
+
branch: str
|
|
69
|
+
nodeState: str
|
|
70
|
+
lastCommitId: str
|
|
71
|
+
locked: bool
|
|
72
|
+
lockReason: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ThetaEntryModel(BaseModel):
|
|
76
|
+
concept: str
|
|
77
|
+
meanConfidence: float
|
|
78
|
+
eventCount: int
|
|
79
|
+
disclosureLevel: Optional[str] = None
|
|
80
|
+
lastUpdated: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SensitivityEventModel(BaseModel):
|
|
84
|
+
id: str
|
|
85
|
+
timestamp: str
|
|
86
|
+
concept: str
|
|
87
|
+
disclosureLevel: int
|
|
88
|
+
signal: str
|
|
89
|
+
confidence: Optional[float] = None
|
|
90
|
+
sourceNode: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BranchSummaryModel(BaseModel):
|
|
94
|
+
name: str
|
|
95
|
+
tipCommitId: str
|
|
96
|
+
commitCount: int
|
|
97
|
+
mcsScore: Optional[float] = None
|
|
98
|
+
gitSha: Optional[str] = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class DashboardSavingsModel(BaseModel):
|
|
102
|
+
tokensUsed: int
|
|
103
|
+
tokensSaved: int
|
|
104
|
+
latencySpeedup: float
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DashboardDataModel(BaseModel):
|
|
108
|
+
status: GCCStatusModel
|
|
109
|
+
theta: List[ThetaEntryModel]
|
|
110
|
+
recentSensitivities: List[SensitivityEventModel]
|
|
111
|
+
branches: List[BranchSummaryModel]
|
|
112
|
+
lastUpdated: str
|
|
113
|
+
githubUrl: Optional[str] = None
|
|
114
|
+
savings: DashboardSavingsModel
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RDPBudgetModel(BaseModel):
|
|
118
|
+
epsilon_used: float
|
|
119
|
+
epsilon_max: float
|
|
120
|
+
read_only: bool
|
|
121
|
+
exhausted: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ConformanceModel(BaseModel):
|
|
125
|
+
I1_commit_backed: bool
|
|
126
|
+
I3_semantic_grounding: bool
|
|
127
|
+
A1_capability_gate: bool
|
|
128
|
+
A2_variance_control: bool
|
|
129
|
+
A3_sis_quarantine: bool
|
|
130
|
+
A4_deltaf_bound: bool
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ProjectSummaryModel(BaseModel):
|
|
134
|
+
name: str
|
|
135
|
+
path: str
|
|
136
|
+
branch: str
|
|
137
|
+
lastCommitId: str
|
|
138
|
+
lastActivity: str
|
|
139
|
+
tokensUsed30d: int
|
|
140
|
+
tokensSaved30d: int
|
|
141
|
+
latencySpeedup: float
|
|
142
|
+
healthScore: Optional[float] = None
|
|
143
|
+
healthLabel: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class OrgDependencyNodeModel(BaseModel):
|
|
147
|
+
id: str
|
|
148
|
+
name: str
|
|
149
|
+
path: str
|
|
150
|
+
healthScore: Optional[float] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class OrgDependencyEdgeModel(BaseModel):
|
|
154
|
+
source: str
|
|
155
|
+
target: str
|
|
156
|
+
concept: str
|
|
157
|
+
weight: float
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class OrgDependencyGraphModel(BaseModel):
|
|
161
|
+
nodes: List[OrgDependencyNodeModel]
|
|
162
|
+
edges: List[OrgDependencyEdgeModel]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ProjectsSavingsModel(BaseModel):
|
|
166
|
+
projects: List[ProjectSummaryModel]
|
|
167
|
+
aggregateTokensUsed: int
|
|
168
|
+
aggregateTokensSaved: int
|
|
169
|
+
aggregateLatencySpeedup: float
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SensitivitySummaryModel(BaseModel):
|
|
173
|
+
total: int
|
|
174
|
+
by_disclosure_level: Dict[str, int]
|
|
175
|
+
high_disclosure_count: int
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CISODataModel(BaseModel):
|
|
179
|
+
rdp_budget: RDPBudgetModel
|
|
180
|
+
conformance: ConformanceModel
|
|
181
|
+
sensitivity_summary: SensitivitySummaryModel
|
|
182
|
+
sis_quarantine_count: int
|
|
183
|
+
variance_alert_count: int
|
|
184
|
+
last_pst_passed: Optional[bool] = None
|
|
185
|
+
last_updated: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# S13 / S14 models -----------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
class ForensicEventModel(BaseModel):
|
|
191
|
+
ts: str
|
|
192
|
+
type: str
|
|
193
|
+
detail: str
|
|
194
|
+
raw: Dict[str, Any]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ForensicTimelineModel(BaseModel):
|
|
198
|
+
events: List[ForensicEventModel]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class AgentContributionModel(BaseModel):
|
|
202
|
+
agent_id: str
|
|
203
|
+
confidence: float
|
|
204
|
+
message: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class CollisionModel(BaseModel):
|
|
208
|
+
concept: str
|
|
209
|
+
agents: List[AgentContributionModel]
|
|
210
|
+
severity: str # "HIGH" | "MEDIUM" | "LOW"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SprintSummaryModel(BaseModel):
|
|
214
|
+
total_commits: int
|
|
215
|
+
commits_per_branch: Dict[str, int]
|
|
216
|
+
i3_violations: int
|
|
217
|
+
collisions_detected: int
|
|
218
|
+
top_concepts: List[ThetaEntryModel]
|
|
219
|
+
mcs_note: str
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class MCSTrendPointModel(BaseModel):
|
|
223
|
+
date: str # ISO date "2026-04-13"
|
|
224
|
+
mcs: Optional[float] # None = no events that day
|
|
225
|
+
eventCount: int
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AuditExportMetaModel(BaseModel):
|
|
229
|
+
exported_at: str
|
|
230
|
+
gcc_version: str
|
|
231
|
+
event_count: int
|
|
232
|
+
events: List[Dict[str, Any]]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ShadowAIModel(BaseModel):
|
|
236
|
+
git_commits: int
|
|
237
|
+
gcc_commits: int
|
|
238
|
+
unregistered: int
|
|
239
|
+
shadow_ratio: float
|
|
240
|
+
flagged: bool
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class CompliancePostureModel(BaseModel):
|
|
244
|
+
event_log_integrity: bool
|
|
245
|
+
i1_compliance_rate: float
|
|
246
|
+
rdp_budget_exhausted: bool
|
|
247
|
+
rdp_read_only: bool
|
|
248
|
+
private_span_count: int
|
|
249
|
+
sis_quarantine_count: int
|
|
250
|
+
variance_alert_count: int
|
|
251
|
+
overall_score: int
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class GovernancePolicyModel(BaseModel):
|
|
255
|
+
allowed_models: List[str]
|
|
256
|
+
max_tokens_per_session: int
|
|
257
|
+
racp_compliance_required: bool
|
|
258
|
+
racp_compliance_threshold: float
|
|
259
|
+
require_devtorch_commit: bool
|
|
260
|
+
blocked_concepts: List[str]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class ConceptDriftResponseModel(BaseModel):
|
|
264
|
+
concepts: List[str]
|
|
265
|
+
time_labels: List[str]
|
|
266
|
+
matrix: List[List[float]]
|
|
267
|
+
mean_confidences: List[List[float]]
|
|
268
|
+
event_counts: List[List[int]]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class SprintMetricsModel(BaseModel):
|
|
272
|
+
sprint_id: str
|
|
273
|
+
mcs_score: float
|
|
274
|
+
i3_violations: int
|
|
275
|
+
collisions_prevented: int
|
|
276
|
+
override_rate: float
|
|
277
|
+
acceptance_rate: float
|
|
278
|
+
branch_count: int
|
|
279
|
+
commit_count: int
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class DeliveryTimeTrendPointModel(BaseModel):
|
|
283
|
+
date: str
|
|
284
|
+
current_min: float
|
|
285
|
+
baseline_min: float
|
|
286
|
+
delta_min: float
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# GCC reader helpers (read-only; never write)
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
GCC_DIR_NAME = ".GCC"
|
|
294
|
+
REFS_DIR_NAME = "refs"
|
|
295
|
+
BRANCHES_DIR_NAME = "branches"
|
|
296
|
+
HEAD_FILE_NAME = "HEAD"
|
|
297
|
+
COMMITS_DIR_NAME = "commits"
|
|
298
|
+
NODE_STATE_FILE_NAME = "NODE_STATE"
|
|
299
|
+
CONSENSUS_LOCK_FILE = "consensus_lock.json"
|
|
300
|
+
SENSITIVITIES_DIR_NAME = "sensitivities"
|
|
301
|
+
SENSITIVITY_EVENTS_FILE = "events.jsonl"
|
|
302
|
+
THETA_FILE = "theta.json"
|
|
303
|
+
|
|
304
|
+
# Map string disclosure levels to int for the API model
|
|
305
|
+
_DISCLOSURE_INT: Dict[str, int] = {
|
|
306
|
+
"PUBLIC": 0,
|
|
307
|
+
"PROTECTED": 1,
|
|
308
|
+
"PRIVATE": 2,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _gcc_dir(gcc_repo: Path) -> Path:
|
|
313
|
+
return gcc_repo / GCC_DIR_NAME
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _is_initialized(gcc_repo: Path) -> bool:
|
|
317
|
+
return (_gcc_dir(gcc_repo) / "VERSION").exists()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _read_current_branch(gcc_repo: Path) -> str:
|
|
321
|
+
head = _gcc_dir(gcc_repo) / REFS_DIR_NAME / HEAD_FILE_NAME
|
|
322
|
+
if not head.exists():
|
|
323
|
+
return "main"
|
|
324
|
+
return head.read_text(encoding="utf-8").strip() or "main"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _read_branch_tip(gcc_repo: Path, branch: str) -> str:
|
|
328
|
+
ref = _gcc_dir(gcc_repo) / REFS_DIR_NAME / BRANCHES_DIR_NAME / branch
|
|
329
|
+
if not ref.exists():
|
|
330
|
+
return ""
|
|
331
|
+
return ref.read_text(encoding="utf-8").strip()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _read_node_state(gcc_repo: Path) -> str:
|
|
335
|
+
node_state_file = _gcc_dir(gcc_repo) / NODE_STATE_FILE_NAME
|
|
336
|
+
if not node_state_file.exists():
|
|
337
|
+
return "ACTIVE"
|
|
338
|
+
try:
|
|
339
|
+
data = json.loads(node_state_file.read_text(encoding="utf-8"))
|
|
340
|
+
mode = data.get("mode", "idle")
|
|
341
|
+
# Map internal modes to display states
|
|
342
|
+
mode_map = {
|
|
343
|
+
"running": "ACTIVE",
|
|
344
|
+
"idle": "ACTIVE",
|
|
345
|
+
"paused": "DEGRADED",
|
|
346
|
+
"error": "ISOLATED",
|
|
347
|
+
}
|
|
348
|
+
return mode_map.get(mode.lower(), mode.upper())
|
|
349
|
+
except (json.JSONDecodeError, OSError):
|
|
350
|
+
return "ACTIVE"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _read_lock_state(gcc_repo: Path) -> tuple[bool, Optional[str]]:
|
|
354
|
+
lock_file = _gcc_dir(gcc_repo) / CONSENSUS_LOCK_FILE
|
|
355
|
+
if not lock_file.exists():
|
|
356
|
+
return False, None
|
|
357
|
+
try:
|
|
358
|
+
data = json.loads(lock_file.read_text(encoding="utf-8"))
|
|
359
|
+
locked = bool(data.get("locked", False))
|
|
360
|
+
reason = data.get("reason") if locked else None
|
|
361
|
+
return locked, reason
|
|
362
|
+
except (json.JSONDecodeError, OSError):
|
|
363
|
+
return False, None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _read_gcc_status(gcc_repo: Path) -> GCCStatusModel:
|
|
367
|
+
branch = _read_current_branch(gcc_repo)
|
|
368
|
+
last_commit_id = _read_branch_tip(gcc_repo, branch)
|
|
369
|
+
node_state = _read_node_state(gcc_repo)
|
|
370
|
+
locked, lock_reason = _read_lock_state(gcc_repo)
|
|
371
|
+
return GCCStatusModel(
|
|
372
|
+
branch=branch,
|
|
373
|
+
nodeState=node_state,
|
|
374
|
+
lastCommitId=last_commit_id,
|
|
375
|
+
locked=locked,
|
|
376
|
+
lockReason=lock_reason,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _compute_health_score(gcc_repo: Path) -> Optional[float]:
|
|
381
|
+
"""Best-effort Deployment Health Score proxy (0-100)."""
|
|
382
|
+
try:
|
|
383
|
+
from devtorch_core.metrics.shadow_ai import detect_shadow_ai
|
|
384
|
+
from devtorch_core.metrics.dhs import DHSComponents, compute_dhs
|
|
385
|
+
|
|
386
|
+
shadow = detect_shadow_ai(gcc_repo.parent)
|
|
387
|
+
components = DHSComponents(
|
|
388
|
+
commit_health=1.0 if shadow.get("gcc_commits", 0) > 0 else 0.0,
|
|
389
|
+
sensitivity_health=1.0,
|
|
390
|
+
capability_health=0.0,
|
|
391
|
+
variance_health=1.0,
|
|
392
|
+
)
|
|
393
|
+
return compute_dhs(components)
|
|
394
|
+
except Exception:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _health_label(score: Optional[float]) -> str:
|
|
399
|
+
if score is None:
|
|
400
|
+
return "UNKNOWN"
|
|
401
|
+
if score >= 90:
|
|
402
|
+
return "AUTONOMOUS"
|
|
403
|
+
if score >= 75:
|
|
404
|
+
return "STANDARD"
|
|
405
|
+
if score >= 60:
|
|
406
|
+
return "REVIEW"
|
|
407
|
+
return "BLOCKED"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _read_theta(gcc_repo: Path) -> List[ThetaEntryModel]:
|
|
411
|
+
theta_file = _gcc_dir(gcc_repo) / THETA_FILE
|
|
412
|
+
if not theta_file.exists():
|
|
413
|
+
return []
|
|
414
|
+
try:
|
|
415
|
+
data = json.loads(theta_file.read_text(encoding="utf-8"))
|
|
416
|
+
cv: Dict[str, Any] = data.get("coordination_vector", {})
|
|
417
|
+
entries: List[ThetaEntryModel] = []
|
|
418
|
+
for concept, info in cv.items():
|
|
419
|
+
entries.append(
|
|
420
|
+
ThetaEntryModel(
|
|
421
|
+
concept=concept,
|
|
422
|
+
meanConfidence=float(info.get("mean_confidence", 0.0)),
|
|
423
|
+
eventCount=int(info.get("event_count", 0)),
|
|
424
|
+
disclosureLevel=info.get("disclosure_level"),
|
|
425
|
+
lastUpdated=info.get("last_updated"),
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
# Sort by confidence descending for consistent presentation
|
|
429
|
+
entries.sort(key=lambda e: e.meanConfidence, reverse=True)
|
|
430
|
+
return entries
|
|
431
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _disclosure_str_to_int(level: str) -> int:
|
|
436
|
+
"""Convert disclosure level string to integer. Supports both str and int inputs."""
|
|
437
|
+
if isinstance(level, int):
|
|
438
|
+
return level
|
|
439
|
+
return _DISCLOSURE_INT.get(str(level).upper(), 0)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _read_sensitivities(gcc_repo: Path, limit: int = 50, concept: Optional[str] = None) -> List[SensitivityEventModel]:
|
|
443
|
+
events_file = (
|
|
444
|
+
_gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
|
|
445
|
+
)
|
|
446
|
+
if not events_file.exists():
|
|
447
|
+
return []
|
|
448
|
+
raw_events: List[dict] = []
|
|
449
|
+
try:
|
|
450
|
+
with events_file.open("r", encoding="utf-8") as fh:
|
|
451
|
+
for line in fh:
|
|
452
|
+
line = line.strip()
|
|
453
|
+
if not line:
|
|
454
|
+
continue
|
|
455
|
+
try:
|
|
456
|
+
raw_events.append(json.loads(line))
|
|
457
|
+
except json.JSONDecodeError:
|
|
458
|
+
continue
|
|
459
|
+
except OSError:
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
# Newest first; apply concept filter before slicing so limit is per-concept
|
|
463
|
+
raw_events = raw_events[::-1]
|
|
464
|
+
if concept:
|
|
465
|
+
raw_events = [
|
|
466
|
+
ev for ev in raw_events
|
|
467
|
+
if ev.get("target_concept", ev.get("concept", "")) == concept
|
|
468
|
+
]
|
|
469
|
+
raw_events = raw_events[:limit]
|
|
470
|
+
|
|
471
|
+
result: List[SensitivityEventModel] = []
|
|
472
|
+
for ev in raw_events:
|
|
473
|
+
ev_concept = ev.get("target_concept", ev.get("concept", "unknown"))
|
|
474
|
+
signal = ev.get("counterfactuals", ev.get("message", ev.get("signal", "")))
|
|
475
|
+
timestamp = ev.get("created_at", ev.get("timestamp", ""))
|
|
476
|
+
disclosure_raw = ev.get("disclosure_level", "PUBLIC")
|
|
477
|
+
raw_conf = ev.get("confidence")
|
|
478
|
+
confidence = float(raw_conf) if raw_conf is not None else None
|
|
479
|
+
result.append(
|
|
480
|
+
SensitivityEventModel(
|
|
481
|
+
id=ev.get("event_id", ev.get("id", "")),
|
|
482
|
+
timestamp=timestamp,
|
|
483
|
+
concept=ev_concept,
|
|
484
|
+
disclosureLevel=_disclosure_str_to_int(disclosure_raw),
|
|
485
|
+
signal=str(signal),
|
|
486
|
+
confidence=confidence,
|
|
487
|
+
sourceNode=ev.get("source_node"),
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
return result
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _list_branch_names(gcc_repo: Path) -> List[str]:
|
|
494
|
+
branches_dir = _gcc_dir(gcc_repo) / REFS_DIR_NAME / BRANCHES_DIR_NAME
|
|
495
|
+
if not branches_dir.exists():
|
|
496
|
+
return []
|
|
497
|
+
return sorted(
|
|
498
|
+
p.name for p in branches_dir.iterdir() if p.is_file()
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _count_commits(gcc_repo: Path, branch: str) -> int:
|
|
503
|
+
"""Walk commit chain for branch and count commits."""
|
|
504
|
+
tip = _read_branch_tip(gcc_repo, branch)
|
|
505
|
+
if not tip:
|
|
506
|
+
return 0
|
|
507
|
+
commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
|
|
508
|
+
count = 0
|
|
509
|
+
current = tip
|
|
510
|
+
visited: set = set()
|
|
511
|
+
while current and current not in visited:
|
|
512
|
+
visited.add(current)
|
|
513
|
+
commit_file = commits_dir / f"{current}.json"
|
|
514
|
+
if not commit_file.exists():
|
|
515
|
+
break
|
|
516
|
+
try:
|
|
517
|
+
obj = json.loads(commit_file.read_text(encoding="utf-8"))
|
|
518
|
+
except (json.JSONDecodeError, OSError):
|
|
519
|
+
break
|
|
520
|
+
count += 1
|
|
521
|
+
current = obj.get("parent") or ""
|
|
522
|
+
return count
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _get_github_url(gcc_repo: Path) -> Optional[str]:
|
|
526
|
+
"""Return the GitHub web URL for the repo (https://github.com/user/repo), or None."""
|
|
527
|
+
try:
|
|
528
|
+
r = subprocess.run(
|
|
529
|
+
["git", "remote", "get-url", "origin"],
|
|
530
|
+
capture_output=True, text=True, cwd=gcc_repo, timeout=5,
|
|
531
|
+
)
|
|
532
|
+
if r.returncode != 0:
|
|
533
|
+
return None
|
|
534
|
+
url = r.stdout.strip()
|
|
535
|
+
# SSH → HTTPS: git@github.com:user/repo.git → https://github.com/user/repo
|
|
536
|
+
if url.startswith("git@"):
|
|
537
|
+
url = url.replace(":", "/", 1).replace("git@", "https://")
|
|
538
|
+
if url.endswith(".git"):
|
|
539
|
+
url = url[:-4]
|
|
540
|
+
return url or None
|
|
541
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _get_git_sha(gcc_repo: Path, branch: str) -> Optional[str]:
|
|
546
|
+
"""Return the full git SHA for the tip of a branch, or None if not found."""
|
|
547
|
+
try:
|
|
548
|
+
r = subprocess.run(
|
|
549
|
+
["git", "rev-parse", branch],
|
|
550
|
+
capture_output=True, text=True, cwd=gcc_repo, timeout=5,
|
|
551
|
+
)
|
|
552
|
+
if r.returncode != 0:
|
|
553
|
+
return None
|
|
554
|
+
sha = r.stdout.strip()
|
|
555
|
+
return sha if sha else None
|
|
556
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _read_branches(gcc_repo: Path) -> List[BranchSummaryModel]:
|
|
561
|
+
names = _list_branch_names(gcc_repo)
|
|
562
|
+
result: List[BranchSummaryModel] = []
|
|
563
|
+
for name in names:
|
|
564
|
+
tip = _read_branch_tip(gcc_repo, name)
|
|
565
|
+
commit_count = _count_commits(gcc_repo, name)
|
|
566
|
+
result.append(
|
|
567
|
+
BranchSummaryModel(
|
|
568
|
+
name=name,
|
|
569
|
+
tipCommitId=tip,
|
|
570
|
+
commitCount=commit_count,
|
|
571
|
+
mcsScore=None, # MCS not yet computed per-branch in S13
|
|
572
|
+
gitSha=_get_git_sha(gcc_repo, name),
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _read_rdp_budget(gcc_repo: Path) -> RDPBudgetModel:
|
|
579
|
+
rdp_file = _gcc_dir(gcc_repo) / "rdp" / "rdp_state.json"
|
|
580
|
+
defaults = RDPBudgetModel(epsilon_used=0.0, epsilon_max=1.0, read_only=False, exhausted=False)
|
|
581
|
+
if not rdp_file.exists():
|
|
582
|
+
return defaults
|
|
583
|
+
try:
|
|
584
|
+
data = json.loads(rdp_file.read_text(encoding="utf-8"))
|
|
585
|
+
epsilon_used = float(data.get("epsilon_used", 0.0))
|
|
586
|
+
epsilon_max = float(data.get("epsilon_max", data.get("epsilon_budget", 1.0)))
|
|
587
|
+
read_only = bool(data.get("read_only", False))
|
|
588
|
+
exhausted = bool(data.get("exhausted", read_only))
|
|
589
|
+
return RDPBudgetModel(
|
|
590
|
+
epsilon_used=epsilon_used,
|
|
591
|
+
epsilon_max=epsilon_max,
|
|
592
|
+
read_only=read_only,
|
|
593
|
+
exhausted=exhausted,
|
|
594
|
+
)
|
|
595
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
|
596
|
+
return defaults
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _read_conformance(gcc_repo: Path) -> ConformanceModel:
|
|
600
|
+
gcc = _gcc_dir(gcc_repo)
|
|
601
|
+
# I1: commits/ non-empty
|
|
602
|
+
try:
|
|
603
|
+
commits_dir = gcc / "commits"
|
|
604
|
+
i1 = commits_dir.exists() and any(True for _ in commits_dir.iterdir())
|
|
605
|
+
except OSError:
|
|
606
|
+
i1 = False
|
|
607
|
+
# I3: concepts/ non-empty
|
|
608
|
+
try:
|
|
609
|
+
concepts_dir = gcc / "concepts"
|
|
610
|
+
i3 = concepts_dir.exists() and any(True for _ in concepts_dir.iterdir())
|
|
611
|
+
except OSError:
|
|
612
|
+
i3 = False
|
|
613
|
+
# A1: capabilities/omega.json exists
|
|
614
|
+
a1 = (gcc / "capabilities" / "omega.json").exists()
|
|
615
|
+
# A2: variance/ directory exists
|
|
616
|
+
a2 = (gcc / "variance").exists()
|
|
617
|
+
# A3: sis/ directory exists
|
|
618
|
+
a3 = (gcc / "sis").exists()
|
|
619
|
+
# A4: deltaf/deltaf_report.json exists
|
|
620
|
+
a4 = (gcc / "deltaf" / "deltaf_report.json").exists()
|
|
621
|
+
return ConformanceModel(
|
|
622
|
+
I1_commit_backed=i1,
|
|
623
|
+
I3_semantic_grounding=i3,
|
|
624
|
+
A1_capability_gate=a1,
|
|
625
|
+
A2_variance_control=a2,
|
|
626
|
+
A3_sis_quarantine=a3,
|
|
627
|
+
A4_deltaf_bound=a4,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _read_sensitivity_summary(gcc_repo: Path) -> SensitivitySummaryModel:
|
|
632
|
+
events_file = _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
|
|
633
|
+
by_level: Dict[str, int] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
|
|
634
|
+
total = 0
|
|
635
|
+
if events_file.exists():
|
|
636
|
+
try:
|
|
637
|
+
with events_file.open("r", encoding="utf-8") as fh:
|
|
638
|
+
for line in fh:
|
|
639
|
+
line = line.strip()
|
|
640
|
+
if not line:
|
|
641
|
+
continue
|
|
642
|
+
try:
|
|
643
|
+
ev = json.loads(line)
|
|
644
|
+
except json.JSONDecodeError:
|
|
645
|
+
continue
|
|
646
|
+
total += 1
|
|
647
|
+
raw = ev.get("disclosure_level", 0)
|
|
648
|
+
# Normalize: string names map to int, numeric strings kept as-is
|
|
649
|
+
if isinstance(raw, str):
|
|
650
|
+
# Try numeric string first
|
|
651
|
+
try:
|
|
652
|
+
level_int = int(raw)
|
|
653
|
+
except ValueError:
|
|
654
|
+
# Named levels: treat PUBLIC=1, PROTECTED=3, PRIVATE=5
|
|
655
|
+
name_map = {
|
|
656
|
+
"PUBLIC": 1,
|
|
657
|
+
"PROTECTED": 3,
|
|
658
|
+
"PRIVATE": 5,
|
|
659
|
+
}
|
|
660
|
+
level_int = name_map.get(raw.upper(), 1)
|
|
661
|
+
else:
|
|
662
|
+
level_int = int(raw)
|
|
663
|
+
key = str(max(1, min(5, level_int)))
|
|
664
|
+
by_level[key] = by_level.get(key, 0) + 1
|
|
665
|
+
except OSError:
|
|
666
|
+
pass
|
|
667
|
+
high_disclosure_count = by_level.get("4", 0) + by_level.get("5", 0)
|
|
668
|
+
return SensitivitySummaryModel(
|
|
669
|
+
total=total,
|
|
670
|
+
by_disclosure_level=by_level,
|
|
671
|
+
high_disclosure_count=high_disclosure_count,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _read_sis_quarantine_count(gcc_repo: Path) -> int:
|
|
676
|
+
sis_report = _gcc_dir(gcc_repo) / "sis" / "sis_tc_report.json"
|
|
677
|
+
if not sis_report.exists():
|
|
678
|
+
return 0
|
|
679
|
+
try:
|
|
680
|
+
data = json.loads(sis_report.read_text(encoding="utf-8"))
|
|
681
|
+
return int(data.get("quarantine_count", data.get("quarantined_count", 0)))
|
|
682
|
+
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
|
683
|
+
return 0
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _read_variance_alert_count(gcc_repo: Path) -> int:
|
|
687
|
+
events_log = _gcc_dir(gcc_repo) / "events.log.jsonl"
|
|
688
|
+
if not events_log.exists():
|
|
689
|
+
return 0
|
|
690
|
+
count = 0
|
|
691
|
+
try:
|
|
692
|
+
with events_log.open("r", encoding="utf-8") as fh:
|
|
693
|
+
for line in fh:
|
|
694
|
+
line = line.strip()
|
|
695
|
+
if not line:
|
|
696
|
+
continue
|
|
697
|
+
try:
|
|
698
|
+
ev = json.loads(line)
|
|
699
|
+
if ev.get("event_type") == "VARIANCE_ALERT":
|
|
700
|
+
count += 1
|
|
701
|
+
except json.JSONDecodeError:
|
|
702
|
+
continue
|
|
703
|
+
except OSError:
|
|
704
|
+
pass
|
|
705
|
+
return count
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _read_last_pst_passed(gcc_repo: Path) -> Optional[bool]:
|
|
709
|
+
pst_dir = _gcc_dir(gcc_repo) / "pst_reports"
|
|
710
|
+
if not pst_dir.exists():
|
|
711
|
+
return None
|
|
712
|
+
try:
|
|
713
|
+
report_files = sorted(
|
|
714
|
+
(f for f in pst_dir.iterdir() if f.suffix == ".json"),
|
|
715
|
+
key=lambda p: p.name,
|
|
716
|
+
)
|
|
717
|
+
if not report_files:
|
|
718
|
+
return None
|
|
719
|
+
latest = report_files[-1]
|
|
720
|
+
data = json.loads(latest.read_text(encoding="utf-8"))
|
|
721
|
+
passed = data.get("passed")
|
|
722
|
+
if passed is None:
|
|
723
|
+
return None
|
|
724
|
+
return bool(passed)
|
|
725
|
+
except (OSError, json.JSONDecodeError):
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _read_ciso_data(gcc_repo: Path) -> CISODataModel:
|
|
730
|
+
rdp_budget = _read_rdp_budget(gcc_repo)
|
|
731
|
+
conformance = _read_conformance(gcc_repo)
|
|
732
|
+
sensitivity_summary = _read_sensitivity_summary(gcc_repo)
|
|
733
|
+
sis_quarantine_count = _read_sis_quarantine_count(gcc_repo)
|
|
734
|
+
variance_alert_count = _read_variance_alert_count(gcc_repo)
|
|
735
|
+
last_pst_passed = _read_last_pst_passed(gcc_repo)
|
|
736
|
+
last_updated = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
|
737
|
+
return CISODataModel(
|
|
738
|
+
rdp_budget=rdp_budget,
|
|
739
|
+
conformance=conformance,
|
|
740
|
+
sensitivity_summary=sensitivity_summary,
|
|
741
|
+
sis_quarantine_count=sis_quarantine_count,
|
|
742
|
+
variance_alert_count=variance_alert_count,
|
|
743
|
+
last_pst_passed=last_pst_passed,
|
|
744
|
+
last_updated=last_updated,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _event_log_integrity_ok(gcc_repo: Path) -> bool:
|
|
749
|
+
"""Best-effort hash-chain integrity check over events.log.jsonl."""
|
|
750
|
+
from devtorch_core.signing import verify_log
|
|
751
|
+
try:
|
|
752
|
+
return len(verify_log(_gcc_dir(gcc_repo))) == 0
|
|
753
|
+
except Exception:
|
|
754
|
+
return False
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _read_governance_policy(gcc_repo: Path) -> GovernancePolicyModel:
|
|
758
|
+
"""Read the project governance policy file (.GCC/policy.json) or return defaults."""
|
|
759
|
+
policy_file = _gcc_dir(gcc_repo) / "policy.json"
|
|
760
|
+
try:
|
|
761
|
+
from devtorch_core.gateway.policy import GovernancePolicy
|
|
762
|
+
policy = GovernancePolicy.load_from_file(str(policy_file))
|
|
763
|
+
except Exception:
|
|
764
|
+
policy = None
|
|
765
|
+
if policy is None:
|
|
766
|
+
return GovernancePolicyModel(
|
|
767
|
+
allowed_models=[],
|
|
768
|
+
max_tokens_per_session=1_000_000,
|
|
769
|
+
racp_compliance_required=False,
|
|
770
|
+
racp_compliance_threshold=0.0,
|
|
771
|
+
require_devtorch_commit=False,
|
|
772
|
+
blocked_concepts=[],
|
|
773
|
+
)
|
|
774
|
+
return GovernancePolicyModel(
|
|
775
|
+
allowed_models=policy.allowed_models,
|
|
776
|
+
max_tokens_per_session=policy.max_tokens_per_session,
|
|
777
|
+
racp_compliance_required=policy.racp_compliance_required,
|
|
778
|
+
racp_compliance_threshold=policy.racp_compliance_threshold,
|
|
779
|
+
require_devtorch_commit=policy.require_devtorch_commit,
|
|
780
|
+
blocked_concepts=policy.blocked_concepts,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _read_compliance_posture(gcc_repo: Path) -> CompliancePostureModel:
|
|
785
|
+
rdp = _read_rdp_budget(gcc_repo)
|
|
786
|
+
shadow = detect_shadow_ai(gcc_repo)
|
|
787
|
+
git_commits = max(1, shadow["git_commits"])
|
|
788
|
+
gcc_commits = shadow["gcc_commits"]
|
|
789
|
+
i1_rate = min(1.0, gcc_commits / git_commits)
|
|
790
|
+
private_count = _read_sensitivity_summary(gcc_repo).high_disclosure_count
|
|
791
|
+
sis_quarantine_count = _read_sis_quarantine_count(gcc_repo)
|
|
792
|
+
variance_alert_count = _read_variance_alert_count(gcc_repo)
|
|
793
|
+
integrity_ok = _event_log_integrity_ok(gcc_repo)
|
|
794
|
+
|
|
795
|
+
score = 100
|
|
796
|
+
if not integrity_ok:
|
|
797
|
+
score -= 25
|
|
798
|
+
if i1_rate < 0.5:
|
|
799
|
+
score -= 25
|
|
800
|
+
if rdp.exhausted:
|
|
801
|
+
score -= 15
|
|
802
|
+
if private_count > 0:
|
|
803
|
+
score -= 10
|
|
804
|
+
if sis_quarantine_count > 0:
|
|
805
|
+
score -= 10
|
|
806
|
+
if variance_alert_count > 0:
|
|
807
|
+
score -= 10
|
|
808
|
+
score = max(0, score)
|
|
809
|
+
|
|
810
|
+
return CompliancePostureModel(
|
|
811
|
+
event_log_integrity=integrity_ok,
|
|
812
|
+
i1_compliance_rate=i1_rate,
|
|
813
|
+
rdp_budget_exhausted=rdp.exhausted,
|
|
814
|
+
rdp_read_only=rdp.read_only,
|
|
815
|
+
private_span_count=private_count,
|
|
816
|
+
sis_quarantine_count=sis_quarantine_count,
|
|
817
|
+
variance_alert_count=variance_alert_count,
|
|
818
|
+
overall_score=score,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _read_org_dependency_graph() -> OrgDependencyGraphModel:
|
|
823
|
+
"""Build a cross-project concept-coupling graph from registered project Θ files."""
|
|
824
|
+
registry = ProjectRegistry()
|
|
825
|
+
project_concepts: Dict[str, Dict[str, float]] = {}
|
|
826
|
+
project_meta: Dict[str, Dict[str, Any]] = {}
|
|
827
|
+
|
|
828
|
+
for entry in registry.list():
|
|
829
|
+
project_root = Path(entry.path)
|
|
830
|
+
gcc_dir = project_root / ".GCC"
|
|
831
|
+
if not gcc_dir.exists():
|
|
832
|
+
continue
|
|
833
|
+
try:
|
|
834
|
+
theta = _read_theta(project_root)
|
|
835
|
+
except Exception:
|
|
836
|
+
continue
|
|
837
|
+
concepts: Dict[str, float] = {}
|
|
838
|
+
for t in theta:
|
|
839
|
+
concepts[t.concept] = t.meanConfidence
|
|
840
|
+
if concepts:
|
|
841
|
+
project_concepts[entry.name] = concepts
|
|
842
|
+
project_meta[entry.name] = {
|
|
843
|
+
"path": entry.path,
|
|
844
|
+
"health_score": _compute_health_score(gcc_dir),
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
nodes = [
|
|
848
|
+
OrgDependencyNodeModel(
|
|
849
|
+
id=name,
|
|
850
|
+
name=name,
|
|
851
|
+
path=meta["path"],
|
|
852
|
+
healthScore=meta["health_score"],
|
|
853
|
+
)
|
|
854
|
+
for name, meta in project_meta.items()
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
edges: List[OrgDependencyEdgeModel] = []
|
|
858
|
+
project_names = list(project_concepts.keys())
|
|
859
|
+
for i in range(len(project_names)):
|
|
860
|
+
for j in range(i + 1, len(project_names)):
|
|
861
|
+
a, b = project_names[i], project_names[j]
|
|
862
|
+
shared = set(project_concepts[a].keys()) & set(project_concepts[b].keys())
|
|
863
|
+
for concept in shared:
|
|
864
|
+
weight = (project_concepts[a][concept] + project_concepts[b][concept]) / 2
|
|
865
|
+
edges.append(OrgDependencyEdgeModel(
|
|
866
|
+
source=a,
|
|
867
|
+
target=b,
|
|
868
|
+
concept=concept,
|
|
869
|
+
weight=weight,
|
|
870
|
+
))
|
|
871
|
+
|
|
872
|
+
return OrgDependencyGraphModel(nodes=nodes, edges=edges)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _read_dashboard(gcc_repo: Path, sensitivity_limit: int = 20) -> DashboardDataModel:
|
|
876
|
+
status = _read_gcc_status(gcc_repo)
|
|
877
|
+
theta = _read_theta(gcc_repo)
|
|
878
|
+
sensitivities = _read_sensitivities(gcc_repo, limit=sensitivity_limit)
|
|
879
|
+
branches = _read_branches(gcc_repo)
|
|
880
|
+
github_url = _get_github_url(gcc_repo)
|
|
881
|
+
tokens = aggregate_tokens(_gcc_dir(gcc_repo))
|
|
882
|
+
speedup = aggregate_speedup(_gcc_dir(gcc_repo))
|
|
883
|
+
last_updated = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
|
|
884
|
+
return DashboardDataModel(
|
|
885
|
+
status=status,
|
|
886
|
+
theta=theta,
|
|
887
|
+
recentSensitivities=sensitivities,
|
|
888
|
+
branches=branches,
|
|
889
|
+
githubUrl=github_url,
|
|
890
|
+
lastUpdated=last_updated,
|
|
891
|
+
savings=DashboardSavingsModel(
|
|
892
|
+
tokensUsed=tokens["total_tokens_used"],
|
|
893
|
+
tokensSaved=tokens["total_tokens_saved"],
|
|
894
|
+
latencySpeedup=speedup["latency_speedup_factor"],
|
|
895
|
+
),
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# ---------------------------------------------------------------------------
|
|
900
|
+
# S13 / S14 reader helpers
|
|
901
|
+
# ---------------------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
EVENT_LOG_FILE = "events.log.jsonl"
|
|
904
|
+
COMMIT_INDEX_FILE = "COMMIT_INDEX"
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _summarize_event(ev: Dict[str, Any]) -> str:
|
|
908
|
+
"""Produce a human-readable one-liner for an event dict."""
|
|
909
|
+
etype = ev.get("event_type", ev.get("type", "UNKNOWN"))
|
|
910
|
+
concept = ev.get("concept", ev.get("target_concept", ""))
|
|
911
|
+
message = ev.get("message", ev.get("detail", ""))
|
|
912
|
+
commit_id = ev.get("commit_id", "")
|
|
913
|
+
branch = ev.get("branch", "")
|
|
914
|
+
|
|
915
|
+
parts: List[str] = []
|
|
916
|
+
if etype:
|
|
917
|
+
parts.append(f"[{etype}]")
|
|
918
|
+
if concept:
|
|
919
|
+
parts.append(f"concept={concept}")
|
|
920
|
+
if branch:
|
|
921
|
+
parts.append(f"branch={branch}")
|
|
922
|
+
if commit_id:
|
|
923
|
+
parts.append(f"commit={commit_id[:8]}")
|
|
924
|
+
if message:
|
|
925
|
+
parts.append(message[:120])
|
|
926
|
+
return " ".join(parts) if parts else str(ev)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _read_events_log(gcc_repo: Path) -> List[Dict[str, Any]]:
|
|
930
|
+
"""Read all events from events.log.jsonl; return empty list on any error."""
|
|
931
|
+
log_file = _gcc_dir(gcc_repo) / EVENT_LOG_FILE
|
|
932
|
+
events: List[Dict[str, Any]] = []
|
|
933
|
+
if not log_file.exists():
|
|
934
|
+
return events
|
|
935
|
+
try:
|
|
936
|
+
with log_file.open("r", encoding="utf-8") as fh:
|
|
937
|
+
for line in fh:
|
|
938
|
+
line = line.strip()
|
|
939
|
+
if not line:
|
|
940
|
+
continue
|
|
941
|
+
try:
|
|
942
|
+
events.append(json.loads(line))
|
|
943
|
+
except json.JSONDecodeError:
|
|
944
|
+
continue
|
|
945
|
+
except OSError:
|
|
946
|
+
pass
|
|
947
|
+
return events
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _read_forensic_timeline(
|
|
951
|
+
gcc_repo: Path,
|
|
952
|
+
limit: int = 100,
|
|
953
|
+
event_types_filter: str = "",
|
|
954
|
+
branch_filter: str = "",
|
|
955
|
+
) -> ForensicTimelineModel:
|
|
956
|
+
raw_events = _read_events_log(gcc_repo)
|
|
957
|
+
|
|
958
|
+
# Apply event type filter
|
|
959
|
+
if event_types_filter:
|
|
960
|
+
wanted = {t.strip().upper() for t in event_types_filter.split(",") if t.strip()}
|
|
961
|
+
raw_events = [
|
|
962
|
+
ev for ev in raw_events
|
|
963
|
+
if ev.get("event_type", ev.get("type", "")).upper() in wanted
|
|
964
|
+
]
|
|
965
|
+
|
|
966
|
+
# Apply branch filter
|
|
967
|
+
if branch_filter:
|
|
968
|
+
raw_events = [
|
|
969
|
+
ev for ev in raw_events
|
|
970
|
+
if ev.get("branch", "").lower() == branch_filter.lower()
|
|
971
|
+
]
|
|
972
|
+
|
|
973
|
+
# Chronological order (already append-only so just slice)
|
|
974
|
+
raw_events = raw_events[:limit]
|
|
975
|
+
|
|
976
|
+
result: List[ForensicEventModel] = []
|
|
977
|
+
for ev in raw_events:
|
|
978
|
+
ts = ev.get("ts", ev.get("timestamp", ev.get("created_at", "")))
|
|
979
|
+
etype = ev.get("event_type", ev.get("type", "UNKNOWN"))
|
|
980
|
+
result.append(
|
|
981
|
+
ForensicEventModel(
|
|
982
|
+
ts=ts,
|
|
983
|
+
type=etype,
|
|
984
|
+
detail=_summarize_event(ev),
|
|
985
|
+
raw=ev,
|
|
986
|
+
)
|
|
987
|
+
)
|
|
988
|
+
return ForensicTimelineModel(events=result)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _read_commit_chain(gcc_repo: Path, commit_id: str, max_depth: int = 10) -> List[Dict[str, Any]]:
|
|
992
|
+
"""Return the commit object + up to max_depth parents."""
|
|
993
|
+
commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
|
|
994
|
+
chain: List[Dict[str, Any]] = []
|
|
995
|
+
current = commit_id
|
|
996
|
+
visited: set = set()
|
|
997
|
+
while current and current not in visited and len(chain) <= max_depth:
|
|
998
|
+
visited.add(current)
|
|
999
|
+
commit_file = commits_dir / f"{current}.json"
|
|
1000
|
+
if not commit_file.exists():
|
|
1001
|
+
break
|
|
1002
|
+
try:
|
|
1003
|
+
obj = json.loads(commit_file.read_text(encoding="utf-8"))
|
|
1004
|
+
chain.append(obj)
|
|
1005
|
+
current = obj.get("parent") or ""
|
|
1006
|
+
except (json.JSONDecodeError, OSError):
|
|
1007
|
+
break
|
|
1008
|
+
return chain
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _read_sensitivity_events_between(
|
|
1012
|
+
gcc_repo: Path, parent_ts: str, child_ts: str
|
|
1013
|
+
) -> List[Dict[str, Any]]:
|
|
1014
|
+
"""Return all sensitivity events whose timestamp falls between parent and child."""
|
|
1015
|
+
events_file = _gcc_dir(gcc_repo) / "sensitivities" / "events.jsonl"
|
|
1016
|
+
result: List[Dict[str, Any]] = []
|
|
1017
|
+
if not events_file.exists():
|
|
1018
|
+
return result
|
|
1019
|
+
try:
|
|
1020
|
+
with events_file.open("r", encoding="utf-8") as fh:
|
|
1021
|
+
for line in fh:
|
|
1022
|
+
line = line.strip()
|
|
1023
|
+
if not line:
|
|
1024
|
+
continue
|
|
1025
|
+
try:
|
|
1026
|
+
ev = json.loads(line)
|
|
1027
|
+
ts = ev.get("created_at", ev.get("timestamp", ""))
|
|
1028
|
+
if parent_ts <= ts <= child_ts:
|
|
1029
|
+
result.append(ev)
|
|
1030
|
+
except json.JSONDecodeError:
|
|
1031
|
+
continue
|
|
1032
|
+
except OSError:
|
|
1033
|
+
pass
|
|
1034
|
+
return result
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _read_invariant_violations_between(
|
|
1038
|
+
gcc_repo: Path, parent_ts: str, child_ts: str
|
|
1039
|
+
) -> List[Dict[str, Any]]:
|
|
1040
|
+
"""Return all INVARIANT_VIOLATION events between the two timestamps."""
|
|
1041
|
+
all_events = _read_events_log(gcc_repo)
|
|
1042
|
+
return [
|
|
1043
|
+
ev for ev in all_events
|
|
1044
|
+
if ev.get("event_type", ev.get("type", "")) == "INVARIANT_VIOLATION"
|
|
1045
|
+
and parent_ts <= ev.get("ts", ev.get("timestamp", "")) <= child_ts
|
|
1046
|
+
]
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _read_collision_list(gcc_repo: Path) -> List[CollisionModel]:
|
|
1050
|
+
"""Detect collisions from theta.json where multiple source_nodes conflict (std_dev > 0.2)."""
|
|
1051
|
+
theta_file = _gcc_dir(gcc_repo) / THETA_FILE
|
|
1052
|
+
if not theta_file.exists():
|
|
1053
|
+
return []
|
|
1054
|
+
try:
|
|
1055
|
+
data = json.loads(theta_file.read_text(encoding="utf-8"))
|
|
1056
|
+
except (json.JSONDecodeError, OSError):
|
|
1057
|
+
return []
|
|
1058
|
+
|
|
1059
|
+
cv: Dict[str, Any] = data.get("coordination_vector", {})
|
|
1060
|
+
collisions: List[CollisionModel] = []
|
|
1061
|
+
|
|
1062
|
+
for concept, info in cv.items():
|
|
1063
|
+
source_nodes: List[Dict[str, Any]] = info.get("source_nodes", [])
|
|
1064
|
+
if not source_nodes or len(source_nodes) < 2:
|
|
1065
|
+
continue
|
|
1066
|
+
|
|
1067
|
+
confidences = [float(n.get("confidence", 0.0)) for n in source_nodes]
|
|
1068
|
+
mean_c = sum(confidences) / len(confidences)
|
|
1069
|
+
variance = sum((c - mean_c) ** 2 for c in confidences) / len(confidences)
|
|
1070
|
+
std_dev = math.sqrt(variance)
|
|
1071
|
+
|
|
1072
|
+
if std_dev <= 0.2:
|
|
1073
|
+
continue
|
|
1074
|
+
|
|
1075
|
+
if std_dev > 0.4:
|
|
1076
|
+
severity = "HIGH"
|
|
1077
|
+
else:
|
|
1078
|
+
severity = "MEDIUM"
|
|
1079
|
+
|
|
1080
|
+
agents: List[AgentContributionModel] = []
|
|
1081
|
+
for node in source_nodes:
|
|
1082
|
+
agents.append(
|
|
1083
|
+
AgentContributionModel(
|
|
1084
|
+
agent_id=str(node.get("agent_id", node.get("node_id", "unknown"))),
|
|
1085
|
+
confidence=float(node.get("confidence", 0.0)),
|
|
1086
|
+
message=str(node.get("message", node.get("signal", ""))),
|
|
1087
|
+
)
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
collisions.append(CollisionModel(concept=concept, agents=agents, severity=severity))
|
|
1091
|
+
|
|
1092
|
+
return collisions
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _read_commit_index(gcc_repo: Path) -> List[str]:
|
|
1096
|
+
"""Read the flat COMMIT_INDEX file and return list of commit IDs."""
|
|
1097
|
+
index_file = _gcc_dir(gcc_repo) / COMMIT_INDEX_FILE
|
|
1098
|
+
if not index_file.exists():
|
|
1099
|
+
return []
|
|
1100
|
+
try:
|
|
1101
|
+
lines = index_file.read_text(encoding="utf-8").splitlines()
|
|
1102
|
+
return [line.strip() for line in lines if line.strip()]
|
|
1103
|
+
except OSError:
|
|
1104
|
+
return []
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _read_sprint_summary(gcc_repo: Path) -> SprintSummaryModel:
|
|
1108
|
+
"""Compute sprint review summary from commits + events."""
|
|
1109
|
+
commit_ids = _read_commit_index(gcc_repo)
|
|
1110
|
+
total_commits = len(commit_ids)
|
|
1111
|
+
|
|
1112
|
+
# commits_per_branch: walk each commit and group by branch field
|
|
1113
|
+
commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
|
|
1114
|
+
commits_per_branch: Dict[str, int] = {}
|
|
1115
|
+
for cid in commit_ids:
|
|
1116
|
+
cfile = commits_dir / f"{cid}.json"
|
|
1117
|
+
if not cfile.exists():
|
|
1118
|
+
continue
|
|
1119
|
+
try:
|
|
1120
|
+
obj = json.loads(cfile.read_text(encoding="utf-8"))
|
|
1121
|
+
branch = obj.get("branch", "main")
|
|
1122
|
+
commits_per_branch[branch] = commits_per_branch.get(branch, 0) + 1
|
|
1123
|
+
except (json.JSONDecodeError, OSError):
|
|
1124
|
+
continue
|
|
1125
|
+
|
|
1126
|
+
# Count I3 violations and collision events from events.log
|
|
1127
|
+
all_events = _read_events_log(gcc_repo)
|
|
1128
|
+
i3_violations = sum(
|
|
1129
|
+
1 for ev in all_events
|
|
1130
|
+
if ev.get("event_type", ev.get("type", "")) == "INVARIANT_VIOLATION"
|
|
1131
|
+
and "I3" in str(ev.get("invariant", ev.get("detail", "")))
|
|
1132
|
+
)
|
|
1133
|
+
# Broaden to any INVARIANT_VIOLATION if no I3-specific found
|
|
1134
|
+
if i3_violations == 0:
|
|
1135
|
+
i3_violations = sum(
|
|
1136
|
+
1 for ev in all_events
|
|
1137
|
+
if ev.get("event_type", ev.get("type", "")) == "INVARIANT_VIOLATION"
|
|
1138
|
+
)
|
|
1139
|
+
collisions_detected = sum(
|
|
1140
|
+
1 for ev in all_events
|
|
1141
|
+
if ev.get("event_type", ev.get("type", "")) == "VARIANCE_ALERT"
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
top_concepts = _read_theta(gcc_repo)[:5]
|
|
1145
|
+
|
|
1146
|
+
return SprintSummaryModel(
|
|
1147
|
+
total_commits=total_commits,
|
|
1148
|
+
commits_per_branch=commits_per_branch,
|
|
1149
|
+
i3_violations=i3_violations,
|
|
1150
|
+
collisions_detected=collisions_detected,
|
|
1151
|
+
top_concepts=top_concepts,
|
|
1152
|
+
mcs_note="MCS weights are DevTorch design choices pending empirical calibration",
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _build_audit_json(gcc_repo: Path, since: str = "") -> AuditExportMetaModel:
|
|
1157
|
+
"""Build the audit export JSON payload."""
|
|
1158
|
+
all_events = _read_events_log(gcc_repo)
|
|
1159
|
+
|
|
1160
|
+
# Apply since filter
|
|
1161
|
+
if since:
|
|
1162
|
+
filtered: List[Dict[str, Any]] = []
|
|
1163
|
+
for ev in all_events:
|
|
1164
|
+
ts = ev.get("ts", ev.get("timestamp", ev.get("created_at", "")))
|
|
1165
|
+
if ts >= since:
|
|
1166
|
+
filtered.append(ev)
|
|
1167
|
+
all_events = filtered
|
|
1168
|
+
|
|
1169
|
+
# Read version
|
|
1170
|
+
version_file = _gcc_dir(gcc_repo) / "VERSION"
|
|
1171
|
+
gcc_version = "unknown"
|
|
1172
|
+
if version_file.exists():
|
|
1173
|
+
try:
|
|
1174
|
+
gcc_version = version_file.read_text(encoding="utf-8").strip()
|
|
1175
|
+
except OSError:
|
|
1176
|
+
pass
|
|
1177
|
+
|
|
1178
|
+
return AuditExportMetaModel(
|
|
1179
|
+
exported_at=_dt.datetime.now(tz=_dt.timezone.utc).isoformat(),
|
|
1180
|
+
gcc_version=gcc_version,
|
|
1181
|
+
event_count=len(all_events),
|
|
1182
|
+
events=all_events,
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _build_audit_text(gcc_repo: Path, since: str = "") -> str:
|
|
1187
|
+
"""Build a human-readable plain-text audit report."""
|
|
1188
|
+
export = _build_audit_json(gcc_repo, since=since)
|
|
1189
|
+
status = _read_gcc_status(gcc_repo)
|
|
1190
|
+
branches = _read_branches(gcc_repo)
|
|
1191
|
+
sensitivity_events = _read_sensitivities(gcc_repo, limit=200)
|
|
1192
|
+
|
|
1193
|
+
now_str = _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
1194
|
+
lines: List[str] = []
|
|
1195
|
+
|
|
1196
|
+
# Header
|
|
1197
|
+
lines.append("=" * 72)
|
|
1198
|
+
lines.append(" DEVTORCH AUDIT REPORT")
|
|
1199
|
+
lines.append(f" Generated: {now_str}")
|
|
1200
|
+
lines.append(f" GCC Version: {export.gcc_version}")
|
|
1201
|
+
lines.append("=" * 72)
|
|
1202
|
+
lines.append("")
|
|
1203
|
+
lines.append(
|
|
1204
|
+
"DISCLAIMER: This report was generated by DevTorch OSS. "
|
|
1205
|
+
"Weights and scores are design choices pending empirical calibration."
|
|
1206
|
+
)
|
|
1207
|
+
lines.append("")
|
|
1208
|
+
|
|
1209
|
+
# Repository Info
|
|
1210
|
+
lines.append("-" * 72)
|
|
1211
|
+
lines.append("REPOSITORY INFO")
|
|
1212
|
+
lines.append("-" * 72)
|
|
1213
|
+
lines.append(f" Current Branch : {status.branch}")
|
|
1214
|
+
lines.append(f" Node State : {status.nodeState}")
|
|
1215
|
+
lines.append(f" Last Commit ID : {status.lastCommitId or '(none)'}")
|
|
1216
|
+
lines.append(f" Locked : {'YES' if status.locked else 'NO'}")
|
|
1217
|
+
if status.lockReason:
|
|
1218
|
+
lines.append(f" Lock Reason : {status.lockReason}")
|
|
1219
|
+
lines.append("")
|
|
1220
|
+
|
|
1221
|
+
# Branch Summary
|
|
1222
|
+
lines.append("-" * 72)
|
|
1223
|
+
lines.append("BRANCH SUMMARY")
|
|
1224
|
+
lines.append("-" * 72)
|
|
1225
|
+
if branches:
|
|
1226
|
+
for br in branches:
|
|
1227
|
+
lines.append(f" {br.name:<20} commits={br.commitCount:<5} tip={br.tipCommitId[:12] if br.tipCommitId else '(empty)'}")
|
|
1228
|
+
else:
|
|
1229
|
+
lines.append(" (no branches)")
|
|
1230
|
+
lines.append("")
|
|
1231
|
+
|
|
1232
|
+
# Event Summary
|
|
1233
|
+
lines.append("-" * 72)
|
|
1234
|
+
lines.append("EVENT SUMMARY")
|
|
1235
|
+
lines.append("-" * 72)
|
|
1236
|
+
lines.append(f" Total events in export: {export.event_count}")
|
|
1237
|
+
if since:
|
|
1238
|
+
lines.append(f" Filtered since: {since}")
|
|
1239
|
+
|
|
1240
|
+
# Group by type
|
|
1241
|
+
type_counts: Dict[str, int] = {}
|
|
1242
|
+
for ev in export.events:
|
|
1243
|
+
etype = ev.get("event_type", ev.get("type", "UNKNOWN"))
|
|
1244
|
+
type_counts[etype] = type_counts.get(etype, 0) + 1
|
|
1245
|
+
if type_counts:
|
|
1246
|
+
lines.append("")
|
|
1247
|
+
lines.append(" Event type breakdown:")
|
|
1248
|
+
for etype, cnt in sorted(type_counts.items()):
|
|
1249
|
+
lines.append(f" {etype:<35} {cnt}")
|
|
1250
|
+
lines.append("")
|
|
1251
|
+
|
|
1252
|
+
# Sensitivity Events
|
|
1253
|
+
lines.append("-" * 72)
|
|
1254
|
+
lines.append("SENSITIVITY EVENTS (most recent 50)")
|
|
1255
|
+
lines.append("-" * 72)
|
|
1256
|
+
if sensitivity_events:
|
|
1257
|
+
for ev in sensitivity_events[:50]:
|
|
1258
|
+
lines.append(
|
|
1259
|
+
f" [{ev.timestamp[:19]}] L{ev.disclosureLevel} {ev.concept:<25} {ev.signal[:60]}"
|
|
1260
|
+
)
|
|
1261
|
+
else:
|
|
1262
|
+
lines.append(" (no sensitivity events)")
|
|
1263
|
+
lines.append("")
|
|
1264
|
+
|
|
1265
|
+
# Commit History
|
|
1266
|
+
lines.append("-" * 72)
|
|
1267
|
+
lines.append("COMMIT HISTORY")
|
|
1268
|
+
lines.append("-" * 72)
|
|
1269
|
+
commit_ids = _read_commit_index(gcc_repo)
|
|
1270
|
+
if commit_ids:
|
|
1271
|
+
commits_dir = _gcc_dir(gcc_repo) / COMMITS_DIR_NAME
|
|
1272
|
+
for cid in commit_ids[-50:]: # last 50
|
|
1273
|
+
cfile = commits_dir / f"{cid}.json"
|
|
1274
|
+
if cfile.exists():
|
|
1275
|
+
try:
|
|
1276
|
+
obj = json.loads(cfile.read_text(encoding="utf-8"))
|
|
1277
|
+
msg = str(obj.get("message", ""))[:60]
|
|
1278
|
+
br = obj.get("branch", "?")
|
|
1279
|
+
ts = str(obj.get("created_at", ""))[:19]
|
|
1280
|
+
lines.append(f" {cid[:12]} [{ts}] ({br}) {msg}")
|
|
1281
|
+
except (json.JSONDecodeError, OSError):
|
|
1282
|
+
lines.append(f" {cid[:12]} (unreadable)")
|
|
1283
|
+
else:
|
|
1284
|
+
lines.append(" (no commits)")
|
|
1285
|
+
lines.append("")
|
|
1286
|
+
|
|
1287
|
+
# Invariant Status
|
|
1288
|
+
lines.append("-" * 72)
|
|
1289
|
+
lines.append("INVARIANT STATUS")
|
|
1290
|
+
lines.append("-" * 72)
|
|
1291
|
+
conformance = _read_conformance(gcc_repo)
|
|
1292
|
+
lines.append(f" I1 Commit-Backed : {'PASS' if conformance.I1_commit_backed else 'FAIL'}")
|
|
1293
|
+
lines.append(f" I3 Semantic Ground : {'PASS' if conformance.I3_semantic_grounding else 'FAIL'}")
|
|
1294
|
+
lines.append(f" A1 Capability Gate : {'PASS' if conformance.A1_capability_gate else 'FAIL'}")
|
|
1295
|
+
lines.append(f" A2 Variance Control : {'PASS' if conformance.A2_variance_control else 'FAIL'}")
|
|
1296
|
+
lines.append(f" A3 SIS Quarantine : {'PASS' if conformance.A3_sis_quarantine else 'FAIL'}")
|
|
1297
|
+
lines.append(f" A4 Deltaf Bound : {'PASS' if conformance.A4_deltaf_bound else 'FAIL'}")
|
|
1298
|
+
lines.append("")
|
|
1299
|
+
lines.append("=" * 72)
|
|
1300
|
+
lines.append("END OF REPORT")
|
|
1301
|
+
lines.append("=" * 72)
|
|
1302
|
+
|
|
1303
|
+
return "\n".join(lines)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _read_mcs_trend(gcc_repo: Path, days: int = 7) -> List[MCSTrendPointModel]:
|
|
1307
|
+
"""
|
|
1308
|
+
Compute a per-day confidence average from sensitivity events for the last
|
|
1309
|
+
`days` days. Used as a proxy for MCS trend until sprint-level MCS scores
|
|
1310
|
+
are persisted by `devtorch merge`.
|
|
1311
|
+
"""
|
|
1312
|
+
events_file = _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
|
|
1313
|
+
today = _dt.date.today()
|
|
1314
|
+
|
|
1315
|
+
# Build a bucket per day: date_str → [confidence, ...]
|
|
1316
|
+
buckets: Dict[str, List[float]] = {}
|
|
1317
|
+
for i in range(days):
|
|
1318
|
+
day = (today - _dt.timedelta(days=days - 1 - i)).isoformat()
|
|
1319
|
+
buckets[day] = []
|
|
1320
|
+
|
|
1321
|
+
if events_file.exists():
|
|
1322
|
+
try:
|
|
1323
|
+
with events_file.open("r", encoding="utf-8") as fh:
|
|
1324
|
+
for line in fh:
|
|
1325
|
+
line = line.strip()
|
|
1326
|
+
if not line:
|
|
1327
|
+
continue
|
|
1328
|
+
try:
|
|
1329
|
+
ev = json.loads(line)
|
|
1330
|
+
except json.JSONDecodeError:
|
|
1331
|
+
continue
|
|
1332
|
+
ts_raw = ev.get("created_at", ev.get("timestamp", ""))
|
|
1333
|
+
if not ts_raw:
|
|
1334
|
+
continue
|
|
1335
|
+
try:
|
|
1336
|
+
day_str = ts_raw[:10] # "2026-04-19"
|
|
1337
|
+
except (TypeError, IndexError):
|
|
1338
|
+
continue
|
|
1339
|
+
if day_str not in buckets:
|
|
1340
|
+
continue
|
|
1341
|
+
conf = ev.get("confidence")
|
|
1342
|
+
if conf is not None:
|
|
1343
|
+
try:
|
|
1344
|
+
buckets[day_str].append(float(conf))
|
|
1345
|
+
except (TypeError, ValueError):
|
|
1346
|
+
pass
|
|
1347
|
+
except OSError:
|
|
1348
|
+
pass
|
|
1349
|
+
|
|
1350
|
+
result: List[MCSTrendPointModel] = []
|
|
1351
|
+
for day_str, confs in buckets.items():
|
|
1352
|
+
mcs = (sum(confs) / len(confs)) if confs else None
|
|
1353
|
+
result.append(MCSTrendPointModel(date=day_str, mcs=mcs, eventCount=len(confs)))
|
|
1354
|
+
return result
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
# ---------------------------------------------------------------------------
|
|
1358
|
+
# S27: Concept drift heatmap
|
|
1359
|
+
# ---------------------------------------------------------------------------
|
|
1360
|
+
|
|
1361
|
+
CONCEPT_DRIFT_MAX_CONCEPTS = 20
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _compute_concept_drift(
|
|
1365
|
+
gcc_repo: Path,
|
|
1366
|
+
days: int = 14,
|
|
1367
|
+
) -> ConceptDriftResponseModel:
|
|
1368
|
+
"""
|
|
1369
|
+
Compute per-concept confidence drift over the last `days` days.
|
|
1370
|
+
|
|
1371
|
+
Drift is defined as the absolute change in mean confidence for a concept
|
|
1372
|
+
between two consecutive day buckets. The first populated bucket for each
|
|
1373
|
+
concept has drift 0.0. Empty buckets inherit the previous mean so that
|
|
1374
|
+
drift resumes when events reappear.
|
|
1375
|
+
"""
|
|
1376
|
+
events_file = _gcc_dir(gcc_repo) / SENSITIVITIES_DIR_NAME / SENSITIVITY_EVENTS_FILE
|
|
1377
|
+
today = _dt.date.today()
|
|
1378
|
+
|
|
1379
|
+
time_labels: List[str] = []
|
|
1380
|
+
for i in range(days):
|
|
1381
|
+
day = (today - _dt.timedelta(days=days - 1 - i)).isoformat()
|
|
1382
|
+
time_labels.append(day)
|
|
1383
|
+
|
|
1384
|
+
# concept -> day -> list of confidence values
|
|
1385
|
+
raw_buckets: Dict[str, Dict[str, List[float]]] = {}
|
|
1386
|
+
if events_file.exists():
|
|
1387
|
+
try:
|
|
1388
|
+
with events_file.open("r", encoding="utf-8") as fh:
|
|
1389
|
+
for line in fh:
|
|
1390
|
+
line = line.strip()
|
|
1391
|
+
if not line:
|
|
1392
|
+
continue
|
|
1393
|
+
try:
|
|
1394
|
+
ev = json.loads(line)
|
|
1395
|
+
except json.JSONDecodeError:
|
|
1396
|
+
continue
|
|
1397
|
+
concept = ev.get("target_concept", ev.get("concept", ""))
|
|
1398
|
+
if not concept:
|
|
1399
|
+
continue
|
|
1400
|
+
ts = ev.get("created_at", ev.get("timestamp", ""))
|
|
1401
|
+
if not ts:
|
|
1402
|
+
continue
|
|
1403
|
+
day_str = ts[:10]
|
|
1404
|
+
if day_str not in time_labels:
|
|
1405
|
+
continue
|
|
1406
|
+
conf = ev.get("confidence")
|
|
1407
|
+
if conf is None:
|
|
1408
|
+
continue
|
|
1409
|
+
try:
|
|
1410
|
+
conf_val = float(conf)
|
|
1411
|
+
except (TypeError, ValueError):
|
|
1412
|
+
continue
|
|
1413
|
+
raw_buckets.setdefault(concept, {}).setdefault(day_str, []).append(conf_val)
|
|
1414
|
+
except OSError:
|
|
1415
|
+
pass
|
|
1416
|
+
|
|
1417
|
+
# Keep the top concepts by total event volume
|
|
1418
|
+
concept_counts = {
|
|
1419
|
+
concept: sum(len(vals) for vals in days.values())
|
|
1420
|
+
for concept, days in raw_buckets.items()
|
|
1421
|
+
}
|
|
1422
|
+
concepts = sorted(
|
|
1423
|
+
concept_counts.keys(),
|
|
1424
|
+
key=lambda c: concept_counts[c],
|
|
1425
|
+
reverse=True,
|
|
1426
|
+
)[:CONCEPT_DRIFT_MAX_CONCEPTS]
|
|
1427
|
+
|
|
1428
|
+
matrix: List[List[float]] = []
|
|
1429
|
+
mean_confidences: List[List[float]] = []
|
|
1430
|
+
event_counts: List[List[int]] = []
|
|
1431
|
+
|
|
1432
|
+
for concept in concepts:
|
|
1433
|
+
days_for_concept = raw_buckets.get(concept, {})
|
|
1434
|
+
means: List[Optional[float]] = []
|
|
1435
|
+
counts: List[int] = []
|
|
1436
|
+
for day in time_labels:
|
|
1437
|
+
vals = days_for_concept.get(day, [])
|
|
1438
|
+
if vals:
|
|
1439
|
+
means.append(sum(vals) / len(vals))
|
|
1440
|
+
counts.append(len(vals))
|
|
1441
|
+
else:
|
|
1442
|
+
means.append(None)
|
|
1443
|
+
counts.append(0)
|
|
1444
|
+
|
|
1445
|
+
drifts: List[float] = []
|
|
1446
|
+
prev_mean: Optional[float] = None
|
|
1447
|
+
for mean in means:
|
|
1448
|
+
if mean is None:
|
|
1449
|
+
drifts.append(0.0)
|
|
1450
|
+
else:
|
|
1451
|
+
if prev_mean is None:
|
|
1452
|
+
drifts.append(0.0)
|
|
1453
|
+
else:
|
|
1454
|
+
drifts.append(abs(mean - prev_mean))
|
|
1455
|
+
prev_mean = mean
|
|
1456
|
+
|
|
1457
|
+
matrix.append(drifts)
|
|
1458
|
+
mean_confidences.append([m if m is not None else 0.0 for m in means])
|
|
1459
|
+
event_counts.append(counts)
|
|
1460
|
+
|
|
1461
|
+
return ConceptDriftResponseModel(
|
|
1462
|
+
concepts=concepts,
|
|
1463
|
+
time_labels=time_labels,
|
|
1464
|
+
matrix=matrix,
|
|
1465
|
+
mean_confidences=mean_confidences,
|
|
1466
|
+
event_counts=event_counts,
|
|
1467
|
+
)
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
# ---------------------------------------------------------------------------
|
|
1471
|
+
# S29: SSE broadcaster and watcher helpers
|
|
1472
|
+
# ---------------------------------------------------------------------------
|
|
1473
|
+
|
|
1474
|
+
_broadcaster: Optional[EventBroadcaster] = None
|
|
1475
|
+
_watcher: Optional[GCCWatcher] = None
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def _ensure_broadcaster(gcc_repo: Path) -> EventBroadcaster:
|
|
1479
|
+
"""Return a global EventBroadcaster and start a GCCWatcher for this repo."""
|
|
1480
|
+
global _broadcaster, _watcher
|
|
1481
|
+
if _broadcaster is None:
|
|
1482
|
+
_broadcaster = EventBroadcaster()
|
|
1483
|
+
if _watcher is None and _is_initialized(gcc_repo):
|
|
1484
|
+
_watcher = GCCWatcher(_gcc_dir(gcc_repo), _broadcaster, poll_interval=0.5)
|
|
1485
|
+
_watcher.start()
|
|
1486
|
+
return _broadcaster
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _stop_broadcaster() -> None:
|
|
1490
|
+
"""Stop the global watcher (useful for tests)."""
|
|
1491
|
+
global _watcher
|
|
1492
|
+
if _watcher is not None:
|
|
1493
|
+
_watcher.stop()
|
|
1494
|
+
_watcher = None
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
# ---------------------------------------------------------------------------
|
|
1498
|
+
# S29: Sprint metrics helpers
|
|
1499
|
+
# ---------------------------------------------------------------------------
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _read_latest_sprint_metrics(gcc_repo: Path) -> Optional[Dict[str, Any]]:
|
|
1503
|
+
"""Return the most recent sprint metrics file, or None if none exist."""
|
|
1504
|
+
sprint_dir = _gcc_dir(gcc_repo) / "metrics" / "sprint"
|
|
1505
|
+
if not sprint_dir.exists():
|
|
1506
|
+
return None
|
|
1507
|
+
files = sorted(
|
|
1508
|
+
(p for p in sprint_dir.iterdir() if p.suffix == ".json"),
|
|
1509
|
+
key=lambda p: p.name,
|
|
1510
|
+
)
|
|
1511
|
+
if not files:
|
|
1512
|
+
return None
|
|
1513
|
+
try:
|
|
1514
|
+
data = json.loads(files[-1].read_text(encoding="utf-8"))
|
|
1515
|
+
if isinstance(data, dict):
|
|
1516
|
+
return data
|
|
1517
|
+
except (json.JSONDecodeError, OSError):
|
|
1518
|
+
pass
|
|
1519
|
+
return None
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _list_sprint_metrics(gcc_repo: Path) -> List[Dict[str, Any]]:
|
|
1523
|
+
"""Return all sprint metrics files sorted by sprint_id."""
|
|
1524
|
+
sprint_dir = _gcc_dir(gcc_repo) / "metrics" / "sprint"
|
|
1525
|
+
if not sprint_dir.exists():
|
|
1526
|
+
return []
|
|
1527
|
+
results: List[Dict[str, Any]] = []
|
|
1528
|
+
for path in sorted(p for p in sprint_dir.iterdir() if p.suffix == ".json"):
|
|
1529
|
+
try:
|
|
1530
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1531
|
+
if isinstance(data, dict):
|
|
1532
|
+
results.append(data)
|
|
1533
|
+
except (json.JSONDecodeError, OSError):
|
|
1534
|
+
continue
|
|
1535
|
+
return results
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
# ---------------------------------------------------------------------------
|
|
1539
|
+
# S29: Delivery-time trend helper
|
|
1540
|
+
# ---------------------------------------------------------------------------
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _session_effective_date(session_path: Path) -> Optional[str]:
|
|
1544
|
+
"""Return ISO date for a session file (created_at field, then mtime)."""
|
|
1545
|
+
try:
|
|
1546
|
+
data = json.loads(session_path.read_text(encoding="utf-8"))
|
|
1547
|
+
if isinstance(data, dict):
|
|
1548
|
+
created_at = data.get("created_at")
|
|
1549
|
+
if created_at:
|
|
1550
|
+
return str(created_at)[:10]
|
|
1551
|
+
except (json.JSONDecodeError, OSError):
|
|
1552
|
+
pass
|
|
1553
|
+
try:
|
|
1554
|
+
mtime = session_path.stat().st_mtime
|
|
1555
|
+
return _dt.datetime.fromtimestamp(mtime, tz=_dt.timezone.utc).date().isoformat()
|
|
1556
|
+
except OSError:
|
|
1557
|
+
return None
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _compute_delivery_time_trend(
|
|
1561
|
+
gcc_repo: Path, days: int = 30
|
|
1562
|
+
) -> List[DeliveryTimeTrendPointModel]:
|
|
1563
|
+
"""
|
|
1564
|
+
Return a per-day delivery-time trend over the last N days.
|
|
1565
|
+
|
|
1566
|
+
Each point uses the team calibration baseline and all sessions whose
|
|
1567
|
+
effective date is <= that day. Points are returned oldest-first.
|
|
1568
|
+
"""
|
|
1569
|
+
metrics_dir = _gcc_dir(gcc_repo) / "metrics"
|
|
1570
|
+
session_dir = metrics_dir / "session"
|
|
1571
|
+
sessions: List[Dict[str, Any]] = []
|
|
1572
|
+
if session_dir.exists():
|
|
1573
|
+
for path in session_dir.glob("*.json"):
|
|
1574
|
+
try:
|
|
1575
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1576
|
+
if isinstance(data, dict):
|
|
1577
|
+
data = dict(data)
|
|
1578
|
+
if "created_at" not in data:
|
|
1579
|
+
mdate = _session_effective_date(path)
|
|
1580
|
+
if mdate:
|
|
1581
|
+
data["created_at"] = f"{mdate}T00:00:00Z"
|
|
1582
|
+
sessions.append(data)
|
|
1583
|
+
except (json.JSONDecodeError, OSError):
|
|
1584
|
+
continue
|
|
1585
|
+
|
|
1586
|
+
# Load calibration from .GCC/metrics/calibration.json if present
|
|
1587
|
+
calibration: Dict[str, Any] = {}
|
|
1588
|
+
calibration_path = metrics_dir / "calibration.json"
|
|
1589
|
+
if calibration_path.exists():
|
|
1590
|
+
try:
|
|
1591
|
+
calibration = json.loads(calibration_path.read_text(encoding="utf-8"))
|
|
1592
|
+
if not isinstance(calibration, dict):
|
|
1593
|
+
calibration = {}
|
|
1594
|
+
except (json.JSONDecodeError, OSError):
|
|
1595
|
+
calibration = {}
|
|
1596
|
+
|
|
1597
|
+
baseline = float(calibration.get("complex_problem_delivery_time_baseline_min", 240.0))
|
|
1598
|
+
|
|
1599
|
+
today = _dt.date.today()
|
|
1600
|
+
time_labels: List[str] = []
|
|
1601
|
+
for i in range(days):
|
|
1602
|
+
day = (today - _dt.timedelta(days=days - 1 - i)).isoformat()
|
|
1603
|
+
time_labels.append(day)
|
|
1604
|
+
|
|
1605
|
+
# Group sessions by effective date
|
|
1606
|
+
sessions_by_day: Dict[str, List[Dict[str, Any]]] = {}
|
|
1607
|
+
for s in sessions:
|
|
1608
|
+
created_at = s.get("created_at", "")
|
|
1609
|
+
day = str(created_at)[:10] if created_at else ""
|
|
1610
|
+
if day:
|
|
1611
|
+
sessions_by_day.setdefault(day, []).append(s)
|
|
1612
|
+
|
|
1613
|
+
cumulative_sessions: List[Dict[str, Any]] = []
|
|
1614
|
+
result: List[DeliveryTimeTrendPointModel] = []
|
|
1615
|
+
for day in time_labels:
|
|
1616
|
+
cumulative_sessions.extend(sessions_by_day.get(day, []))
|
|
1617
|
+
# Build a synthetic metrics_dir with only cumulative sessions
|
|
1618
|
+
summary = compute_delivery_time_summary(
|
|
1619
|
+
metrics_dir,
|
|
1620
|
+
calibration,
|
|
1621
|
+
)
|
|
1622
|
+
# Override session count and recompute current based on cumulative sessions
|
|
1623
|
+
total_tokens = sum(s.get("tokens_used", 0) for s in cumulative_sessions)
|
|
1624
|
+
session_count = len(cumulative_sessions)
|
|
1625
|
+
complexity = 1.0 + (total_tokens / 100_000.0)
|
|
1626
|
+
current = baseline * complexity * (1.0 + session_count * 0.05)
|
|
1627
|
+
result.append(
|
|
1628
|
+
DeliveryTimeTrendPointModel(
|
|
1629
|
+
date=day,
|
|
1630
|
+
current_min=round(current, 2),
|
|
1631
|
+
baseline_min=round(baseline, 2),
|
|
1632
|
+
delta_min=round(current - baseline, 2),
|
|
1633
|
+
)
|
|
1634
|
+
)
|
|
1635
|
+
return result
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
# ---------------------------------------------------------------------------
|
|
1639
|
+
# FastAPI application factory
|
|
1640
|
+
# ---------------------------------------------------------------------------
|
|
1641
|
+
|
|
1642
|
+
def create_app(gcc_repo: Optional[Path] = None) -> "FastAPI":
|
|
1643
|
+
"""
|
|
1644
|
+
Build and return a FastAPI application instance.
|
|
1645
|
+
|
|
1646
|
+
Parameters
|
|
1647
|
+
----------
|
|
1648
|
+
gcc_repo:
|
|
1649
|
+
Path to the project root containing the `.GCC/` directory.
|
|
1650
|
+
Falls back to the current working directory when None.
|
|
1651
|
+
"""
|
|
1652
|
+
if not _FASTAPI_AVAILABLE:
|
|
1653
|
+
raise ImportError(
|
|
1654
|
+
"FastAPI and uvicorn are required for the dashboard API. "
|
|
1655
|
+
"Install them with: pip install fastapi uvicorn"
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
resolved_repo: Path = gcc_repo if gcc_repo is not None else Path.cwd()
|
|
1659
|
+
|
|
1660
|
+
app = FastAPI(
|
|
1661
|
+
title="DevTorch Dashboard API",
|
|
1662
|
+
description="Read-only REST API serving DevTorch governance state from .GCC/",
|
|
1663
|
+
version="0.1.0",
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
app.add_middleware(
|
|
1667
|
+
CORSMiddleware,
|
|
1668
|
+
allow_origins=["*"], # No auth in S13; tightened in enterprise tier
|
|
1669
|
+
allow_credentials=False,
|
|
1670
|
+
allow_methods=["GET"],
|
|
1671
|
+
allow_headers=["*"],
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
# Start the global SSE broadcaster/watcher for this repo.
|
|
1675
|
+
_ensure_broadcaster(resolved_repo)
|
|
1676
|
+
|
|
1677
|
+
def _require_init() -> Path:
|
|
1678
|
+
if not _is_initialized(resolved_repo):
|
|
1679
|
+
raise HTTPException(
|
|
1680
|
+
status_code=503,
|
|
1681
|
+
detail=(
|
|
1682
|
+
f"No initialized .GCC/ directory found at '{resolved_repo}'. "
|
|
1683
|
+
"Run `devtorch init` in the project root first."
|
|
1684
|
+
),
|
|
1685
|
+
)
|
|
1686
|
+
return resolved_repo
|
|
1687
|
+
|
|
1688
|
+
@app.get("/api/v1/dashboard", response_model=DashboardDataModel)
|
|
1689
|
+
def get_dashboard() -> DashboardDataModel:
|
|
1690
|
+
"""Return a full governance snapshot."""
|
|
1691
|
+
repo = _require_init()
|
|
1692
|
+
return _read_dashboard(repo)
|
|
1693
|
+
|
|
1694
|
+
@app.get("/api/v1/branches", response_model=List[BranchSummaryModel])
|
|
1695
|
+
def get_branches() -> List[BranchSummaryModel]:
|
|
1696
|
+
"""Return a list of branch summaries."""
|
|
1697
|
+
repo = _require_init()
|
|
1698
|
+
return _read_branches(repo)
|
|
1699
|
+
|
|
1700
|
+
@app.get("/api/v1/sensitivities", response_model=List[SensitivityEventModel])
|
|
1701
|
+
def get_sensitivities(
|
|
1702
|
+
limit: int = Query(default=50, ge=1, le=500),
|
|
1703
|
+
concept: Optional[str] = Query(default=None),
|
|
1704
|
+
) -> List[SensitivityEventModel]:
|
|
1705
|
+
"""Return sensitivity events, newest first. Filter by concept with ?concept=auth."""
|
|
1706
|
+
repo = _require_init()
|
|
1707
|
+
return _read_sensitivities(repo, limit=limit, concept=concept)
|
|
1708
|
+
|
|
1709
|
+
@app.get("/api/v1/concepts/drift", response_model=ConceptDriftResponseModel)
|
|
1710
|
+
def get_concept_drift(
|
|
1711
|
+
days: int = Query(default=14, ge=1, le=90),
|
|
1712
|
+
) -> ConceptDriftResponseModel:
|
|
1713
|
+
"""Per-concept confidence drift heatmap over the last N days."""
|
|
1714
|
+
repo = _require_init()
|
|
1715
|
+
return _compute_concept_drift(repo, days=days)
|
|
1716
|
+
|
|
1717
|
+
@app.get("/api/v1/ciso", response_model=CISODataModel)
|
|
1718
|
+
def get_ciso_data() -> CISODataModel:
|
|
1719
|
+
"""CISO-focused governance metrics."""
|
|
1720
|
+
# CISO endpoint returns safe defaults for uninitialized repos — never 503
|
|
1721
|
+
return _read_ciso_data(resolved_repo)
|
|
1722
|
+
|
|
1723
|
+
@app.get("/api/v1/shadow-ai", response_model=ShadowAIModel)
|
|
1724
|
+
def get_shadow_ai() -> ShadowAIModel:
|
|
1725
|
+
"""Shadow AI detection: compare git commits to .GCC COMMIT events."""
|
|
1726
|
+
from devtorch_core.metrics.shadow_ai import detect_shadow_ai
|
|
1727
|
+
result = detect_shadow_ai(resolved_repo)
|
|
1728
|
+
return ShadowAIModel(
|
|
1729
|
+
git_commits=result["git_commits"],
|
|
1730
|
+
gcc_commits=result["gcc_commits"],
|
|
1731
|
+
unregistered=result["unregistered"],
|
|
1732
|
+
shadow_ratio=result["shadow_ratio"],
|
|
1733
|
+
flagged=result["flagged"],
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
@app.get("/api/v1/compliance-posture", response_model=CompliancePostureModel)
|
|
1737
|
+
def get_compliance_posture() -> CompliancePostureModel:
|
|
1738
|
+
"""Compliance posture summary for CISO audit."""
|
|
1739
|
+
return _read_compliance_posture(resolved_repo)
|
|
1740
|
+
|
|
1741
|
+
@app.get("/api/v1/governance-policy", response_model=GovernancePolicyModel)
|
|
1742
|
+
def get_governance_policy() -> GovernancePolicyModel:
|
|
1743
|
+
"""Read-only governance policy console for the project."""
|
|
1744
|
+
return _read_governance_policy(resolved_repo)
|
|
1745
|
+
|
|
1746
|
+
@app.get("/api/v1/org/dependencies", response_model=OrgDependencyGraphModel)
|
|
1747
|
+
def get_org_dependencies() -> OrgDependencyGraphModel:
|
|
1748
|
+
"""Cross-project concept coupling graph."""
|
|
1749
|
+
return _read_org_dependency_graph()
|
|
1750
|
+
|
|
1751
|
+
@app.get("/api/v1/health")
|
|
1752
|
+
def get_health() -> dict:
|
|
1753
|
+
"""Simple health probe."""
|
|
1754
|
+
initialized = _is_initialized(resolved_repo)
|
|
1755
|
+
return {
|
|
1756
|
+
"status": "ok" if initialized else "degraded",
|
|
1757
|
+
"initialized": initialized,
|
|
1758
|
+
"gcc_repo": str(resolved_repo),
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
# -----------------------------------------------------------------------
|
|
1762
|
+
# S19: Multi-project endpoints
|
|
1763
|
+
# -----------------------------------------------------------------------
|
|
1764
|
+
|
|
1765
|
+
@app.get("/api/v1/projects", response_model=List[ProjectSummaryModel])
|
|
1766
|
+
def get_projects() -> List[ProjectSummaryModel]:
|
|
1767
|
+
"""List all projects in the registry with health summary."""
|
|
1768
|
+
registry = ProjectRegistry()
|
|
1769
|
+
out: List[ProjectSummaryModel] = []
|
|
1770
|
+
for entry in registry.list():
|
|
1771
|
+
gcc_dir = Path(entry.path) / ".GCC"
|
|
1772
|
+
if not gcc_dir.exists():
|
|
1773
|
+
continue
|
|
1774
|
+
try:
|
|
1775
|
+
status = _read_gcc_status(gcc_dir)
|
|
1776
|
+
except Exception:
|
|
1777
|
+
continue
|
|
1778
|
+
tokens = aggregate_tokens(gcc_dir)
|
|
1779
|
+
speedup = aggregate_speedup(gcc_dir)
|
|
1780
|
+
health_score = _compute_health_score(gcc_dir)
|
|
1781
|
+
out.append(ProjectSummaryModel(
|
|
1782
|
+
name=entry.name,
|
|
1783
|
+
path=entry.path,
|
|
1784
|
+
branch=status.branch,
|
|
1785
|
+
lastCommitId=status.lastCommitId,
|
|
1786
|
+
lastActivity=entry.last_synced[:19],
|
|
1787
|
+
tokensUsed30d=tokens["total_tokens_used"],
|
|
1788
|
+
tokensSaved30d=tokens["total_tokens_saved"],
|
|
1789
|
+
latencySpeedup=speedup["latency_speedup_factor"],
|
|
1790
|
+
healthScore=health_score,
|
|
1791
|
+
healthLabel=_health_label(health_score),
|
|
1792
|
+
))
|
|
1793
|
+
return out
|
|
1794
|
+
|
|
1795
|
+
@app.get("/api/v1/projects/savings", response_model=ProjectsSavingsModel)
|
|
1796
|
+
def get_projects_savings() -> ProjectsSavingsModel:
|
|
1797
|
+
"""Cross-project aggregate of tokens saved and speedup."""
|
|
1798
|
+
registry = ProjectRegistry()
|
|
1799
|
+
projects: List[ProjectSummaryModel] = []
|
|
1800
|
+
total_used = 0
|
|
1801
|
+
total_saved = 0
|
|
1802
|
+
total_full = 0
|
|
1803
|
+
total_bundle = 0
|
|
1804
|
+
for entry in registry.list():
|
|
1805
|
+
gcc_dir = Path(entry.path) / ".GCC"
|
|
1806
|
+
if not gcc_dir.exists():
|
|
1807
|
+
continue
|
|
1808
|
+
try:
|
|
1809
|
+
status = _read_gcc_status(gcc_dir)
|
|
1810
|
+
except Exception:
|
|
1811
|
+
continue
|
|
1812
|
+
tokens = aggregate_tokens(gcc_dir)
|
|
1813
|
+
speedup = aggregate_speedup(gcc_dir)
|
|
1814
|
+
total_used += tokens["total_tokens_used"]
|
|
1815
|
+
total_saved += tokens["total_tokens_saved"]
|
|
1816
|
+
total_full += speedup["total_full_history_tokens"]
|
|
1817
|
+
total_bundle += speedup["total_bundle_tokens"]
|
|
1818
|
+
projects.append(ProjectSummaryModel(
|
|
1819
|
+
name=entry.name,
|
|
1820
|
+
path=entry.path,
|
|
1821
|
+
branch=status.branch,
|
|
1822
|
+
lastCommitId=status.lastCommitId,
|
|
1823
|
+
lastActivity=entry.last_synced[:19],
|
|
1824
|
+
tokensUsed30d=tokens["total_tokens_used"],
|
|
1825
|
+
tokensSaved30d=tokens["total_tokens_saved"],
|
|
1826
|
+
latencySpeedup=speedup["latency_speedup_factor"],
|
|
1827
|
+
healthScore=_compute_health_score(gcc_dir),
|
|
1828
|
+
healthLabel=_health_label(_compute_health_score(gcc_dir)),
|
|
1829
|
+
))
|
|
1830
|
+
weighted_speedup = total_full / total_bundle if total_bundle > 0 else 1.0
|
|
1831
|
+
return ProjectsSavingsModel(
|
|
1832
|
+
projects=projects,
|
|
1833
|
+
aggregateTokensUsed=total_used,
|
|
1834
|
+
aggregateTokensSaved=total_saved,
|
|
1835
|
+
aggregateLatencySpeedup=weighted_speedup,
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
# -----------------------------------------------------------------------
|
|
1839
|
+
# S13: Forensic Replay endpoints
|
|
1840
|
+
# -----------------------------------------------------------------------
|
|
1841
|
+
|
|
1842
|
+
@app.get("/api/v1/forensic/timeline", response_model=ForensicTimelineModel)
|
|
1843
|
+
def get_forensic_timeline(
|
|
1844
|
+
branch: str = Query(default=""),
|
|
1845
|
+
limit: int = Query(default=100, ge=1, le=1000),
|
|
1846
|
+
event_types: str = Query(default=""),
|
|
1847
|
+
) -> ForensicTimelineModel:
|
|
1848
|
+
"""Forensic event timeline from events.log.jsonl."""
|
|
1849
|
+
return _read_forensic_timeline(
|
|
1850
|
+
resolved_repo,
|
|
1851
|
+
limit=limit,
|
|
1852
|
+
event_types_filter=event_types,
|
|
1853
|
+
branch_filter=branch,
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
@app.get("/api/v1/forensic/trace/{commit_id}")
|
|
1857
|
+
def get_forensic_trace(commit_id: str) -> dict:
|
|
1858
|
+
"""Return commit + parent chain + sensitivity events between parent and this commit."""
|
|
1859
|
+
chain = _read_commit_chain(resolved_repo, commit_id)
|
|
1860
|
+
if not chain:
|
|
1861
|
+
return {"commit": {}, "parents": [], "sensitivities": [], "invariant_violations": []}
|
|
1862
|
+
|
|
1863
|
+
commit = chain[0]
|
|
1864
|
+
parents = chain[1:]
|
|
1865
|
+
|
|
1866
|
+
child_ts = str(commit.get("created_at", commit.get("ts", "")))
|
|
1867
|
+
parent_ts = str(parents[0].get("created_at", parents[0].get("ts", ""))) if parents else ""
|
|
1868
|
+
|
|
1869
|
+
sensitivities = _read_sensitivity_events_between(resolved_repo, parent_ts, child_ts)
|
|
1870
|
+
invariant_violations = _read_invariant_violations_between(resolved_repo, parent_ts, child_ts)
|
|
1871
|
+
|
|
1872
|
+
return {
|
|
1873
|
+
"commit": commit,
|
|
1874
|
+
"parents": parents,
|
|
1875
|
+
"sensitivities": sensitivities,
|
|
1876
|
+
"invariant_violations": invariant_violations,
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
@app.get("/api/v1/collisions", response_model=List[CollisionModel])
|
|
1880
|
+
def get_collisions() -> List[CollisionModel]:
|
|
1881
|
+
"""Return concept collisions where agent confidence diverges (std_dev > 0.2)."""
|
|
1882
|
+
return _read_collision_list(resolved_repo)
|
|
1883
|
+
|
|
1884
|
+
@app.get("/api/v1/sprint/summary", response_model=SprintSummaryModel)
|
|
1885
|
+
def get_sprint_summary() -> SprintSummaryModel:
|
|
1886
|
+
"""Sprint review: commit stats, invariant violations, collisions, top concepts."""
|
|
1887
|
+
return _read_sprint_summary(resolved_repo)
|
|
1888
|
+
|
|
1889
|
+
@app.get("/api/v1/mcs/trend", response_model=List[MCSTrendPointModel])
|
|
1890
|
+
def get_mcs_trend(days: int = Query(default=7, ge=1, le=30)) -> List[MCSTrendPointModel]:
|
|
1891
|
+
"""Per-day average AI confidence for the last N days (proxy for MCS trend)."""
|
|
1892
|
+
return _read_mcs_trend(resolved_repo, days=days)
|
|
1893
|
+
|
|
1894
|
+
# -----------------------------------------------------------------------
|
|
1895
|
+
# S29: Real-time SSE and auto sprint metrics
|
|
1896
|
+
# -----------------------------------------------------------------------
|
|
1897
|
+
|
|
1898
|
+
@app.get("/api/v1/events/stream")
|
|
1899
|
+
async def get_event_stream() -> StreamingResponse:
|
|
1900
|
+
"""SSE stream of coordination events from the global broadcaster."""
|
|
1901
|
+
broadcaster = _ensure_broadcaster(resolved_repo)
|
|
1902
|
+
q = broadcaster.subscribe()
|
|
1903
|
+
|
|
1904
|
+
async def generate():
|
|
1905
|
+
try:
|
|
1906
|
+
# Initial connected event
|
|
1907
|
+
yield "data: " + json.dumps({"type": "connected"}) + "\n\n"
|
|
1908
|
+
while True:
|
|
1909
|
+
try:
|
|
1910
|
+
event = await asyncio.wait_for(q.get(), timeout=30.0)
|
|
1911
|
+
yield "data: " + json.dumps(event) + "\n\n"
|
|
1912
|
+
except asyncio.TimeoutError:
|
|
1913
|
+
yield ": keepalive\n\n"
|
|
1914
|
+
finally:
|
|
1915
|
+
broadcaster.unsubscribe(q)
|
|
1916
|
+
|
|
1917
|
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
1918
|
+
|
|
1919
|
+
@app.get("/api/v1/sprint/latest", response_model=Optional[SprintMetricsModel])
|
|
1920
|
+
def get_sprint_latest() -> Optional[SprintMetricsModel]:
|
|
1921
|
+
"""Return the most recent sprint metrics file."""
|
|
1922
|
+
data = _read_latest_sprint_metrics(resolved_repo)
|
|
1923
|
+
if data is None:
|
|
1924
|
+
return None
|
|
1925
|
+
return SprintMetricsModel(**data)
|
|
1926
|
+
|
|
1927
|
+
@app.get("/api/v1/sprint/metrics", response_model=List[SprintMetricsModel])
|
|
1928
|
+
def get_sprint_metrics() -> List[SprintMetricsModel]:
|
|
1929
|
+
"""Return all sprint metrics files, sorted by sprint_id."""
|
|
1930
|
+
items = _list_sprint_metrics(resolved_repo)
|
|
1931
|
+
return [SprintMetricsModel(**d) for d in items]
|
|
1932
|
+
|
|
1933
|
+
@app.get("/api/v1/metrics/delivery-time/trend", response_model=List[DeliveryTimeTrendPointModel])
|
|
1934
|
+
def get_delivery_time_trend(
|
|
1935
|
+
days: int = Query(default=30, ge=1, le=90),
|
|
1936
|
+
) -> List[DeliveryTimeTrendPointModel]:
|
|
1937
|
+
"""Per-day complex-problem delivery-time trend over the last N days."""
|
|
1938
|
+
return _compute_delivery_time_trend(resolved_repo, days=days)
|
|
1939
|
+
|
|
1940
|
+
# -----------------------------------------------------------------------
|
|
1941
|
+
# S14: Audit Export endpoints
|
|
1942
|
+
# -----------------------------------------------------------------------
|
|
1943
|
+
|
|
1944
|
+
@app.get("/api/v1/audit/export.json")
|
|
1945
|
+
def get_audit_export_json(since: str = Query(default="")) -> JSONResponse:
|
|
1946
|
+
"""Download all events as a JSON audit export."""
|
|
1947
|
+
export = _build_audit_json(resolved_repo, since=since)
|
|
1948
|
+
date_str = _dt.date.today().isoformat()
|
|
1949
|
+
return JSONResponse(
|
|
1950
|
+
content=export.model_dump(),
|
|
1951
|
+
headers={
|
|
1952
|
+
"Content-Disposition": f'attachment; filename="devtorch-audit-{date_str}.json"',
|
|
1953
|
+
},
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
@app.get("/api/v1/audit/export.txt", response_class=PlainTextResponse)
|
|
1957
|
+
def get_audit_export_txt(
|
|
1958
|
+
since: str = Query(default=""),
|
|
1959
|
+
) -> PlainTextResponse:
|
|
1960
|
+
"""Download a human-readable plain-text audit report."""
|
|
1961
|
+
report = _build_audit_text(resolved_repo, since=since)
|
|
1962
|
+
date_str = _dt.date.today().isoformat()
|
|
1963
|
+
return PlainTextResponse(
|
|
1964
|
+
content=report,
|
|
1965
|
+
headers={
|
|
1966
|
+
"Content-Disposition": f'attachment; filename="devtorch-audit-{date_str}.txt"',
|
|
1967
|
+
},
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1970
|
+
return app
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
# ---------------------------------------------------------------------------
|
|
1974
|
+
# Launcher
|
|
1975
|
+
# ---------------------------------------------------------------------------
|
|
1976
|
+
|
|
1977
|
+
def run_dashboard_api(
|
|
1978
|
+
port: int = 8766,
|
|
1979
|
+
host: str = "127.0.0.1",
|
|
1980
|
+
gcc_repo: Optional[Path] = None,
|
|
1981
|
+
) -> None:
|
|
1982
|
+
"""
|
|
1983
|
+
Start the dashboard API server.
|
|
1984
|
+
|
|
1985
|
+
Parameters
|
|
1986
|
+
----------
|
|
1987
|
+
port:
|
|
1988
|
+
TCP port to listen on (default 8766).
|
|
1989
|
+
host:
|
|
1990
|
+
Bind address (default 127.0.0.1; use 0.0.0.0 for external access).
|
|
1991
|
+
gcc_repo:
|
|
1992
|
+
Path to the project root. Defaults to CWD.
|
|
1993
|
+
"""
|
|
1994
|
+
if not _FASTAPI_AVAILABLE:
|
|
1995
|
+
raise ImportError(
|
|
1996
|
+
"FastAPI and uvicorn are required. "
|
|
1997
|
+
"Install with: pip install fastapi uvicorn"
|
|
1998
|
+
)
|
|
1999
|
+
resolved = gcc_repo if gcc_repo is not None else Path.cwd()
|
|
2000
|
+
app = create_app(gcc_repo=resolved)
|
|
2001
|
+
uvicorn.run(app, host=host, port=port)
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
if __name__ == "__main__": # pragma: no cover
|
|
2005
|
+
import argparse
|
|
2006
|
+
|
|
2007
|
+
parser = argparse.ArgumentParser(description="DevTorch Dashboard API server")
|
|
2008
|
+
parser.add_argument("--port", type=int, default=8766)
|
|
2009
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
2010
|
+
parser.add_argument("--repo", type=Path, default=Path.cwd())
|
|
2011
|
+
args = parser.parse_args()
|
|
2012
|
+
run_dashboard_api(port=args.port, host=args.host, gcc_repo=args.repo)
|