xrefkit 0.3.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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/dashboard.py
ADDED
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import webbrowser
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from xrefkit.mcp.audit import AUDIT_SCHEMA
|
|
16
|
+
from xrefkit.skillrun import (
|
|
17
|
+
ACCEPTED_CLOSE_STATUSES,
|
|
18
|
+
PHASE_SECTIONS,
|
|
19
|
+
QUALITY_REQUIRED_TIERS,
|
|
20
|
+
_log_model_tier,
|
|
21
|
+
_log_skill_id,
|
|
22
|
+
_parse_artifacts,
|
|
23
|
+
_parse_concerns,
|
|
24
|
+
_parse_work_items,
|
|
25
|
+
_section_status,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
30
|
+
DEFAULT_PORT = 8765
|
|
31
|
+
PHASES = ("execution", "check", "quality", "closure", "handoff")
|
|
32
|
+
EXPLICIT_XID_RE = re.compile(
|
|
33
|
+
r"(?:<!--\s*xid\s*:\s*|#xid-|xid-|XID\s+)([A-Za-z0-9][A-Za-z0-9_-]{5,})",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
AVAILABLE_XID_RE = re.compile(r"^- xid: `(?P<xid>[^`]+)`", re.MULTILINE)
|
|
37
|
+
BACKTICK_TOKEN_RE = re.compile(r"`(?P<token>[A-Za-z0-9][A-Za-z0-9_-]{5,})`")
|
|
38
|
+
OBSERVATION_EVENT_RE = re.compile(r"^- event: (?P<event>\{.*\})$", re.MULTILINE)
|
|
39
|
+
NON_XID_TOKEN_PREFIXES = ("WI-", "OUT-", "EVD-", "CHK-", "HND-", "UNK-", "RISK-", "JDG-")
|
|
40
|
+
FIELD_RE_TEMPLATE = r"^- {name}:\s*`?(?P<value>[^`\r\n]+)`?\s*$"
|
|
41
|
+
|
|
42
|
+
MISSING_INFORMATION_DEFINITIONS = {
|
|
43
|
+
"run_id": ("Run correlation ID", "No run_id is recorded for cross-log correlation."),
|
|
44
|
+
"mcp_session_id": ("MCP session ID", "No MCP session ID links this run to server-side XID queries."),
|
|
45
|
+
"repository_fingerprint": (
|
|
46
|
+
"Repository fingerprint",
|
|
47
|
+
"No repository fingerprint identifies the Knowledge source generation.",
|
|
48
|
+
),
|
|
49
|
+
"skill_routing_trace": (
|
|
50
|
+
"Skill routing trace",
|
|
51
|
+
"Skill candidates, ranking, and the selection reason are not recorded.",
|
|
52
|
+
),
|
|
53
|
+
"loaded_xid_trace": (
|
|
54
|
+
"Loaded XID trace",
|
|
55
|
+
"XIDs actually injected into model context are not recorded separately from selected XIDs.",
|
|
56
|
+
),
|
|
57
|
+
"knowledge_application_trace": (
|
|
58
|
+
"Knowledge application trace",
|
|
59
|
+
"No explicit XID link from a runtime artifact or concern shows where Knowledge was applied.",
|
|
60
|
+
),
|
|
61
|
+
"knowledge_search_trace": (
|
|
62
|
+
"Knowledge search trace",
|
|
63
|
+
"Knowledge search queries, misses, and fallback decisions are not recorded.",
|
|
64
|
+
),
|
|
65
|
+
"human_feedback": (
|
|
66
|
+
"Human feedback",
|
|
67
|
+
"No human correction, rejection, or acceptance feedback is linked to this run.",
|
|
68
|
+
),
|
|
69
|
+
"outcome_feedback": (
|
|
70
|
+
"Outcome feedback",
|
|
71
|
+
"No downstream or operational outcome is linked to this run.",
|
|
72
|
+
),
|
|
73
|
+
"token_usage": ("Token usage", "Measured input, output, and total token usage is not recorded."),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class DashboardRun:
|
|
79
|
+
path: str
|
|
80
|
+
name: str
|
|
81
|
+
mtime: str
|
|
82
|
+
skill_id: str
|
|
83
|
+
run_id: str | None
|
|
84
|
+
mcp_session_id: str | None
|
|
85
|
+
repository_fingerprint: str | None
|
|
86
|
+
status: str
|
|
87
|
+
closure_status: str
|
|
88
|
+
quality_required: bool
|
|
89
|
+
quality_status: str
|
|
90
|
+
sections: dict[str, str]
|
|
91
|
+
counts: dict[str, int]
|
|
92
|
+
blockers: list[str]
|
|
93
|
+
artifacts: list[dict[str, str]]
|
|
94
|
+
concerns: list[dict[str, str]]
|
|
95
|
+
work_items: list[dict[str, str]]
|
|
96
|
+
available_xids: list[str]
|
|
97
|
+
selected_xids: list[str]
|
|
98
|
+
used_xids: list[str]
|
|
99
|
+
unused_xids: list[str]
|
|
100
|
+
queried_xids: list[str]
|
|
101
|
+
loaded_xids: list[str]
|
|
102
|
+
queried_not_loaded_xids: list[str]
|
|
103
|
+
loaded_not_applied_xids: list[str]
|
|
104
|
+
observation_events: list[dict[str, object]]
|
|
105
|
+
mcp_events: list[dict[str, object]]
|
|
106
|
+
missing_information: list[dict[str, str]]
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, object]:
|
|
109
|
+
return {
|
|
110
|
+
"path": self.path,
|
|
111
|
+
"name": self.name,
|
|
112
|
+
"mtime": self.mtime,
|
|
113
|
+
"skill_id": self.skill_id,
|
|
114
|
+
"run_id": self.run_id,
|
|
115
|
+
"mcp_session_id": self.mcp_session_id,
|
|
116
|
+
"repository_fingerprint": self.repository_fingerprint,
|
|
117
|
+
"status": self.status,
|
|
118
|
+
"closure_status": self.closure_status,
|
|
119
|
+
"quality_required": self.quality_required,
|
|
120
|
+
"quality_status": self.quality_status,
|
|
121
|
+
"sections": self.sections,
|
|
122
|
+
"counts": self.counts,
|
|
123
|
+
"blockers": self.blockers,
|
|
124
|
+
"artifacts": self.artifacts,
|
|
125
|
+
"concerns": self.concerns,
|
|
126
|
+
"work_items": self.work_items,
|
|
127
|
+
"available_xids": self.available_xids,
|
|
128
|
+
"selected_xids": self.selected_xids,
|
|
129
|
+
"used_xids": self.used_xids,
|
|
130
|
+
"unused_xids": self.unused_xids,
|
|
131
|
+
"queried_xids": self.queried_xids,
|
|
132
|
+
"loaded_xids": self.loaded_xids,
|
|
133
|
+
"queried_not_loaded_xids": self.queried_not_loaded_xids,
|
|
134
|
+
"loaded_not_applied_xids": self.loaded_not_applied_xids,
|
|
135
|
+
"observation_events": self.observation_events,
|
|
136
|
+
"mcp_events": self.mcp_events,
|
|
137
|
+
"missing_information": self.missing_information,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_skill_run_log(text: str) -> bool:
|
|
142
|
+
return text.lstrip().startswith("# Skill Run Log") or (
|
|
143
|
+
"- skill_id: `" in text and "## Skill Load Gate" in text and "## Closure Gate" in text
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _rel(path: Path, root: Path) -> str:
|
|
148
|
+
try:
|
|
149
|
+
return path.relative_to(root).as_posix()
|
|
150
|
+
except ValueError:
|
|
151
|
+
return path.as_posix()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _section_status_or_pending(text: str, section: str) -> str:
|
|
155
|
+
return _section_status(text, section) or "pending"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _status_from_blockers(blockers: list[str], closure_status: str) -> str:
|
|
159
|
+
if closure_status in ACCEPTED_CLOSE_STATUSES and not blockers:
|
|
160
|
+
return "closed"
|
|
161
|
+
if blockers:
|
|
162
|
+
return "blocked"
|
|
163
|
+
return "open"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _section_text(text: str, heading: str) -> str:
|
|
167
|
+
marker = f"### {heading}\n"
|
|
168
|
+
start = text.find(marker)
|
|
169
|
+
if start == -1:
|
|
170
|
+
return ""
|
|
171
|
+
next_h3 = text.find("\n### ", start + len(marker))
|
|
172
|
+
next_h2 = text.find("\n## ", start + len(marker))
|
|
173
|
+
candidates = [value for value in (next_h3, next_h2) if value != -1]
|
|
174
|
+
end = min(candidates) if candidates else len(text)
|
|
175
|
+
return text[start:end]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _field_value(text: str, name: str) -> str | None:
|
|
179
|
+
pattern = re.compile(FIELD_RE_TEMPLATE.format(name=re.escape(name)), re.MULTILINE)
|
|
180
|
+
match = pattern.search(text)
|
|
181
|
+
if match is None:
|
|
182
|
+
return None
|
|
183
|
+
value = match.group("value").strip()
|
|
184
|
+
return None if value in {"", "-", "pending", "unknown", "unset"} else value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _observation_events(text: str) -> list[dict[str, object]]:
|
|
188
|
+
events: list[dict[str, object]] = []
|
|
189
|
+
for match in OBSERVATION_EVENT_RE.finditer(text):
|
|
190
|
+
try:
|
|
191
|
+
event = json.loads(match.group("event"))
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
continue
|
|
194
|
+
if isinstance(event, dict) and isinstance(event.get("event"), str):
|
|
195
|
+
events.append(event)
|
|
196
|
+
return events
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _load_mcp_audit(path: Path) -> tuple[dict[str, list[dict[str, object]]], list[str]]:
|
|
200
|
+
by_run: dict[str, list[dict[str, object]]] = {}
|
|
201
|
+
errors: list[str] = []
|
|
202
|
+
if not path.exists():
|
|
203
|
+
return by_run, errors
|
|
204
|
+
try:
|
|
205
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
206
|
+
except (OSError, UnicodeError) as exc:
|
|
207
|
+
errors.append(f"{path}: cannot read audit log: {exc}")
|
|
208
|
+
return by_run, errors
|
|
209
|
+
for line_number, raw in enumerate(lines, start=1):
|
|
210
|
+
if not raw.strip():
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
event = json.loads(raw)
|
|
214
|
+
except json.JSONDecodeError as exc:
|
|
215
|
+
errors.append(f"{path}:{line_number}: invalid JSON: {exc.msg}")
|
|
216
|
+
continue
|
|
217
|
+
if not isinstance(event, dict):
|
|
218
|
+
errors.append(f"{path}:{line_number}: audit event must be an object")
|
|
219
|
+
continue
|
|
220
|
+
run_id = str(event.get("run_id") or "").strip()
|
|
221
|
+
if run_id:
|
|
222
|
+
by_run.setdefault(run_id, []).append(event)
|
|
223
|
+
return by_run, errors
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validated_mcp_events(
|
|
227
|
+
events: list[dict[str, object]],
|
|
228
|
+
*,
|
|
229
|
+
run_id: str | None,
|
|
230
|
+
skill_id: str,
|
|
231
|
+
mcp_session_id: str | None,
|
|
232
|
+
repository_fingerprint: str | None,
|
|
233
|
+
audit_errors: list[str],
|
|
234
|
+
source_path: Path,
|
|
235
|
+
) -> list[dict[str, object]]:
|
|
236
|
+
if not events:
|
|
237
|
+
return []
|
|
238
|
+
if not run_id or not mcp_session_id or not repository_fingerprint:
|
|
239
|
+
audit_errors.append(f"{source_path}: audit events ignored because the Skill Run correlation is incomplete")
|
|
240
|
+
return []
|
|
241
|
+
expected = {
|
|
242
|
+
"schema": AUDIT_SCHEMA,
|
|
243
|
+
"run_id": run_id,
|
|
244
|
+
"skill_id": skill_id,
|
|
245
|
+
"mcp_session_id": mcp_session_id,
|
|
246
|
+
"repository_fingerprint": repository_fingerprint,
|
|
247
|
+
}
|
|
248
|
+
valid = [
|
|
249
|
+
event
|
|
250
|
+
for event in events
|
|
251
|
+
if all(event.get(field) == value for field, value in expected.items())
|
|
252
|
+
and isinstance(event.get("event_type"), str)
|
|
253
|
+
]
|
|
254
|
+
if len(valid) != len(events):
|
|
255
|
+
audit_errors.append(f"{source_path}: ignored MCP audit events with mismatched correlation identity")
|
|
256
|
+
if not any(event.get("event_type") == "run.bound" for event in valid):
|
|
257
|
+
audit_errors.append(f"{source_path}: ignored MCP audit events without a matching run.bound event")
|
|
258
|
+
return []
|
|
259
|
+
return valid
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _missing_information(
|
|
263
|
+
text: str,
|
|
264
|
+
*,
|
|
265
|
+
run_id: str | None,
|
|
266
|
+
mcp_session_id: str | None,
|
|
267
|
+
repository_fingerprint: str | None,
|
|
268
|
+
selected_xids: list[str],
|
|
269
|
+
queried_xids: list[str],
|
|
270
|
+
loaded_xids: list[str],
|
|
271
|
+
applied_xids: list[str],
|
|
272
|
+
observation_events: list[dict[str, object]],
|
|
273
|
+
mcp_events: list[dict[str, object]],
|
|
274
|
+
) -> list[dict[str, str]]:
|
|
275
|
+
missing_codes: list[str] = []
|
|
276
|
+
if run_id is None:
|
|
277
|
+
missing_codes.append("run_id")
|
|
278
|
+
if mcp_session_id is None:
|
|
279
|
+
missing_codes.append("mcp_session_id")
|
|
280
|
+
if repository_fingerprint is None:
|
|
281
|
+
missing_codes.append("repository_fingerprint")
|
|
282
|
+
|
|
283
|
+
local_types = {str(item.get("event")) for item in observation_events}
|
|
284
|
+
mcp_types = {str(item.get("event_type")) for item in mcp_events}
|
|
285
|
+
if "skill.routed" not in local_types and "skill.ranked" not in mcp_types:
|
|
286
|
+
missing_codes.append("skill_routing_trace")
|
|
287
|
+
if (selected_xids or queried_xids) and not loaded_xids:
|
|
288
|
+
missing_codes.append("loaded_xid_trace")
|
|
289
|
+
if loaded_xids and not applied_xids:
|
|
290
|
+
missing_codes.append("knowledge_application_trace")
|
|
291
|
+
if "knowledge.search" not in local_types and "knowledge.search" not in mcp_types:
|
|
292
|
+
missing_codes.append("knowledge_search_trace")
|
|
293
|
+
if "human.feedback" not in local_types:
|
|
294
|
+
missing_codes.append("human_feedback")
|
|
295
|
+
if "outcome.feedback" not in local_types:
|
|
296
|
+
missing_codes.append("outcome_feedback")
|
|
297
|
+
|
|
298
|
+
token_section = _section_text(text, "Token Usage")
|
|
299
|
+
if not token_section:
|
|
300
|
+
token_section = text[text.find("## Token Usage") :] if "## Token Usage" in text else ""
|
|
301
|
+
if _field_value(token_section, "total") is None:
|
|
302
|
+
missing_codes.append("token_usage")
|
|
303
|
+
|
|
304
|
+
return [
|
|
305
|
+
{"code": code, "label": MISSING_INFORMATION_DEFINITIONS[code][0], "detail": MISSING_INFORMATION_DEFINITIONS[code][1]}
|
|
306
|
+
for code in missing_codes
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _normalize_xid(value: str) -> str:
|
|
311
|
+
return value.strip("`.,;:()[]")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _looks_like_xid(value: str) -> bool:
|
|
315
|
+
xid = _normalize_xid(value)
|
|
316
|
+
if len(xid) < 6:
|
|
317
|
+
return False
|
|
318
|
+
if any(separator in xid for separator in ("/", "\\", ".", ":", " ")):
|
|
319
|
+
return False
|
|
320
|
+
upper = xid.upper()
|
|
321
|
+
if upper.startswith(NON_XID_TOKEN_PREFIXES):
|
|
322
|
+
return False
|
|
323
|
+
return any(char.isdigit() for char in xid)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _extract_explicit_xids(value: str) -> list[str]:
|
|
327
|
+
xids: set[str] = set()
|
|
328
|
+
for match in EXPLICIT_XID_RE.finditer(value):
|
|
329
|
+
xid = _normalize_xid(match.group(1))
|
|
330
|
+
if _looks_like_xid(xid):
|
|
331
|
+
xids.add(xid)
|
|
332
|
+
return sorted(xids)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _extract_backtick_xids(value: str) -> list[str]:
|
|
336
|
+
xids: set[str] = set()
|
|
337
|
+
for match in BACKTICK_TOKEN_RE.finditer(value):
|
|
338
|
+
xid = _normalize_xid(match.group("token"))
|
|
339
|
+
if _looks_like_xid(xid):
|
|
340
|
+
xids.add(xid)
|
|
341
|
+
return sorted(xids)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _domain_available_xids(text: str) -> list[str]:
|
|
345
|
+
section = _section_text(text, "Available Domain Knowledge")
|
|
346
|
+
return sorted({match.group("xid").strip() for match in AVAILABLE_XID_RE.finditer(section)})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _domain_selected_xids(text: str) -> list[str]:
|
|
350
|
+
section = _section_text(text, "Selected Knowledge Inputs")
|
|
351
|
+
return sorted(set(_extract_explicit_xids(section)) | set(_extract_backtick_xids(section)))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _runtime_used_xids(artifacts: list[dict[str, str]], concerns: list[dict[str, str]]) -> list[str]:
|
|
355
|
+
xids: set[str] = set()
|
|
356
|
+
for artifact in artifacts:
|
|
357
|
+
target = artifact.get("target", "")
|
|
358
|
+
note = artifact.get("note", "")
|
|
359
|
+
if _looks_like_xid(target):
|
|
360
|
+
xids.add(_normalize_xid(target))
|
|
361
|
+
xids.update(_extract_explicit_xids(note))
|
|
362
|
+
for concern in concerns:
|
|
363
|
+
target = concern.get("target", "")
|
|
364
|
+
text = concern.get("text", "")
|
|
365
|
+
if _looks_like_xid(target):
|
|
366
|
+
xids.add(_normalize_xid(target))
|
|
367
|
+
xids.update(_extract_explicit_xids(text))
|
|
368
|
+
return sorted(xids)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _parse_one_run(
|
|
372
|
+
path: Path,
|
|
373
|
+
root: Path,
|
|
374
|
+
mcp_events_by_run: dict[str, list[dict[str, object]]],
|
|
375
|
+
audit_errors: list[str],
|
|
376
|
+
) -> DashboardRun | None:
|
|
377
|
+
text = path.read_text(encoding="utf-8")
|
|
378
|
+
if not _is_skill_run_log(text):
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
artifacts = _parse_artifacts(text)
|
|
382
|
+
concerns = _parse_concerns(text)
|
|
383
|
+
work_items = _parse_work_items(text)
|
|
384
|
+
model_tier = _log_model_tier(text)
|
|
385
|
+
quality_required = model_tier in QUALITY_REQUIRED_TIERS
|
|
386
|
+
closure_status = _section_status_or_pending(text, "Closure Gate")
|
|
387
|
+
quality_status = _section_status_or_pending(text, "Quality Gate")
|
|
388
|
+
sections = {
|
|
389
|
+
phase: _section_status_or_pending(text, section)
|
|
390
|
+
for phase, section in PHASE_SECTIONS.items()
|
|
391
|
+
if phase in PHASES
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
blockers: list[str] = []
|
|
395
|
+
if "## Skill Load Gate\n\n- status: `opened_by_xrefkit_skill_run`" not in text:
|
|
396
|
+
blockers.append("missing Skill Load Gate")
|
|
397
|
+
for phase in ("execution", "check", "handoff"):
|
|
398
|
+
status = sections.get(phase, "pending")
|
|
399
|
+
if status not in ACCEPTED_CLOSE_STATUSES:
|
|
400
|
+
blockers.append(f"{phase} is {status}")
|
|
401
|
+
if quality_required and quality_status not in ACCEPTED_CLOSE_STATUSES:
|
|
402
|
+
blockers.append(f"quality gate is {quality_status}")
|
|
403
|
+
if not work_items:
|
|
404
|
+
blockers.append("no work items")
|
|
405
|
+
for item in work_items:
|
|
406
|
+
if item["status"] not in ACCEPTED_CLOSE_STATUSES:
|
|
407
|
+
blockers.append(f"work item {item['item_id']} is {item['status']}")
|
|
408
|
+
artifact_kinds = {artifact["kind"] for artifact in artifacts}
|
|
409
|
+
if "output" not in artifact_kinds:
|
|
410
|
+
blockers.append("no output artifact")
|
|
411
|
+
if "evidence" not in artifact_kinds:
|
|
412
|
+
blockers.append("no evidence artifact")
|
|
413
|
+
if quality_required and "check" not in artifact_kinds:
|
|
414
|
+
blockers.append("no quality check artifact")
|
|
415
|
+
for artifact in artifacts:
|
|
416
|
+
if artifact["status"] not in ACCEPTED_CLOSE_STATUSES:
|
|
417
|
+
blockers.append(f"artifact {artifact['artifact_id']} is {artifact['status']}")
|
|
418
|
+
for concern in concerns:
|
|
419
|
+
if concern["status"] not in {"resolved", "escalated"}:
|
|
420
|
+
blockers.append(f"{concern['kind']} {concern['concern_id']} is {concern['status']}")
|
|
421
|
+
|
|
422
|
+
counts = {
|
|
423
|
+
"work_items": len(work_items),
|
|
424
|
+
"artifacts": len(artifacts),
|
|
425
|
+
"outputs": sum(1 for artifact in artifacts if artifact["kind"] == "output"),
|
|
426
|
+
"evidence": sum(1 for artifact in artifacts if artifact["kind"] == "evidence"),
|
|
427
|
+
"checks": sum(1 for artifact in artifacts if artifact["kind"] == "check"),
|
|
428
|
+
"handoffs": sum(1 for artifact in artifacts if artifact["kind"] == "handoff"),
|
|
429
|
+
"unknowns": sum(1 for concern in concerns if concern["kind"] == "unknown"),
|
|
430
|
+
"risks": sum(1 for concern in concerns if concern["kind"] == "risk"),
|
|
431
|
+
"judgments": sum(1 for concern in concerns if concern["kind"] == "judgment"),
|
|
432
|
+
}
|
|
433
|
+
available_xids = _domain_available_xids(text)
|
|
434
|
+
selected_xids = _domain_selected_xids(text)
|
|
435
|
+
run_id = _field_value(text, "run_id")
|
|
436
|
+
skill_id = _log_skill_id(text) or "unknown"
|
|
437
|
+
observation_events = _observation_events(text)
|
|
438
|
+
mcp_session_id = _field_value(text, "mcp_session_id")
|
|
439
|
+
repository_fingerprint = _field_value(text, "repository_fingerprint")
|
|
440
|
+
mcp_events = _validated_mcp_events(
|
|
441
|
+
mcp_events_by_run.get(run_id, []) if run_id else [],
|
|
442
|
+
run_id=run_id,
|
|
443
|
+
skill_id=skill_id,
|
|
444
|
+
mcp_session_id=mcp_session_id,
|
|
445
|
+
repository_fingerprint=repository_fingerprint,
|
|
446
|
+
audit_errors=audit_errors,
|
|
447
|
+
source_path=path,
|
|
448
|
+
)
|
|
449
|
+
loaded_pairs = {
|
|
450
|
+
(str(event["xid"]), str(event["content_hash"]))
|
|
451
|
+
for event in observation_events
|
|
452
|
+
if event.get("event") == "knowledge.loaded"
|
|
453
|
+
and event.get("xid")
|
|
454
|
+
and event.get("content_hash")
|
|
455
|
+
}
|
|
456
|
+
resolved_pairs = {
|
|
457
|
+
(str(event["xid"]), str(event["content_hash"]))
|
|
458
|
+
for event in mcp_events
|
|
459
|
+
if event.get("event_type") == "xid.resolved"
|
|
460
|
+
and event.get("xid")
|
|
461
|
+
and event.get("content_hash")
|
|
462
|
+
}
|
|
463
|
+
applied_pairs = {
|
|
464
|
+
(str(event["xid"]), str(event["content_hash"]))
|
|
465
|
+
for event in observation_events
|
|
466
|
+
if event.get("event") == "knowledge.applied"
|
|
467
|
+
and event.get("xid")
|
|
468
|
+
and event.get("content_hash")
|
|
469
|
+
and (str(event["xid"]), str(event["content_hash"])) in loaded_pairs
|
|
470
|
+
}
|
|
471
|
+
queried_xids = sorted({xid for xid, _ in resolved_pairs})
|
|
472
|
+
loaded_xids = sorted({xid for xid, _ in loaded_pairs})
|
|
473
|
+
applied_xids = sorted({xid for xid, _ in applied_pairs})
|
|
474
|
+
used_xids = applied_xids
|
|
475
|
+
unused_xids = sorted(set(available_xids) - set(used_xids))
|
|
476
|
+
missing_information = _missing_information(
|
|
477
|
+
text,
|
|
478
|
+
run_id=run_id,
|
|
479
|
+
mcp_session_id=mcp_session_id,
|
|
480
|
+
repository_fingerprint=repository_fingerprint,
|
|
481
|
+
selected_xids=selected_xids,
|
|
482
|
+
queried_xids=queried_xids,
|
|
483
|
+
loaded_xids=loaded_xids,
|
|
484
|
+
applied_xids=applied_xids,
|
|
485
|
+
observation_events=observation_events,
|
|
486
|
+
mcp_events=mcp_events,
|
|
487
|
+
)
|
|
488
|
+
stat = path.stat()
|
|
489
|
+
mtime = datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds")
|
|
490
|
+
return DashboardRun(
|
|
491
|
+
path=_rel(path, root),
|
|
492
|
+
name=path.name,
|
|
493
|
+
mtime=mtime,
|
|
494
|
+
skill_id=skill_id,
|
|
495
|
+
run_id=run_id,
|
|
496
|
+
mcp_session_id=mcp_session_id,
|
|
497
|
+
repository_fingerprint=repository_fingerprint,
|
|
498
|
+
status=_status_from_blockers(blockers, closure_status),
|
|
499
|
+
closure_status=closure_status,
|
|
500
|
+
quality_required=quality_required,
|
|
501
|
+
quality_status=quality_status,
|
|
502
|
+
sections=sections,
|
|
503
|
+
counts=counts,
|
|
504
|
+
blockers=blockers,
|
|
505
|
+
artifacts=artifacts,
|
|
506
|
+
concerns=concerns,
|
|
507
|
+
work_items=work_items,
|
|
508
|
+
available_xids=available_xids,
|
|
509
|
+
selected_xids=selected_xids,
|
|
510
|
+
used_xids=used_xids,
|
|
511
|
+
unused_xids=unused_xids,
|
|
512
|
+
queried_xids=queried_xids,
|
|
513
|
+
loaded_xids=loaded_xids,
|
|
514
|
+
queried_not_loaded_xids=sorted({xid for xid, content_hash in resolved_pairs if (xid, content_hash) not in loaded_pairs}),
|
|
515
|
+
loaded_not_applied_xids=sorted({xid for xid, content_hash in loaded_pairs if (xid, content_hash) not in applied_pairs}),
|
|
516
|
+
observation_events=observation_events,
|
|
517
|
+
mcp_events=mcp_events,
|
|
518
|
+
missing_information=missing_information,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def collect_runs(
|
|
523
|
+
root: Path,
|
|
524
|
+
sessions_dir: Path,
|
|
525
|
+
mcp_events_by_run: dict[str, list[dict[str, object]]] | None = None,
|
|
526
|
+
audit_errors: list[str] | None = None,
|
|
527
|
+
) -> list[DashboardRun]:
|
|
528
|
+
root = root.resolve()
|
|
529
|
+
sessions_dir = sessions_dir.resolve()
|
|
530
|
+
if not sessions_dir.exists():
|
|
531
|
+
return []
|
|
532
|
+
runs: list[DashboardRun] = []
|
|
533
|
+
for path in sorted(sessions_dir.rglob("*.md")):
|
|
534
|
+
if not path.is_file():
|
|
535
|
+
continue
|
|
536
|
+
run = _parse_one_run(path, root, mcp_events_by_run or {}, audit_errors if audit_errors is not None else [])
|
|
537
|
+
if run is not None:
|
|
538
|
+
runs.append(run)
|
|
539
|
+
runs.sort(key=lambda item: item.mtime, reverse=True)
|
|
540
|
+
return runs
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _summary(runs: list[DashboardRun]) -> dict[str, int]:
|
|
544
|
+
return {
|
|
545
|
+
"runs": len(runs),
|
|
546
|
+
"closed": sum(1 for run in runs if run.status == "closed"),
|
|
547
|
+
"blocked": sum(1 for run in runs if run.status == "blocked"),
|
|
548
|
+
"open": sum(1 for run in runs if run.status == "open"),
|
|
549
|
+
"unknowns": sum(run.counts["unknowns"] for run in runs),
|
|
550
|
+
"risks": sum(run.counts["risks"] for run in runs),
|
|
551
|
+
"handoffs": sum(run.counts["handoffs"] for run in runs),
|
|
552
|
+
"used_xids": len({xid for run in runs for xid in run.used_xids}),
|
|
553
|
+
"unused_xids": len({xid for run in runs for xid in run.unused_xids}),
|
|
554
|
+
"runs_with_missing_information": sum(1 for run in runs if run.missing_information),
|
|
555
|
+
"missing_information": sum(len(run.missing_information) for run in runs),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _unused_xid_ranking(runs: list[DashboardRun]) -> list[dict[str, object]]:
|
|
560
|
+
ranking: dict[str, dict[str, object]] = {}
|
|
561
|
+
for run in runs:
|
|
562
|
+
for xid in run.unused_xids:
|
|
563
|
+
entry = ranking.setdefault(xid, {"xid": xid, "count": 0, "skills": set(), "runs": []})
|
|
564
|
+
entry["count"] = int(entry["count"]) + 1
|
|
565
|
+
assert isinstance(entry["skills"], set)
|
|
566
|
+
assert isinstance(entry["runs"], list)
|
|
567
|
+
entry["skills"].add(run.skill_id)
|
|
568
|
+
entry["runs"].append(run.path)
|
|
569
|
+
rows: list[dict[str, object]] = []
|
|
570
|
+
for entry in ranking.values():
|
|
571
|
+
rows.append(
|
|
572
|
+
{
|
|
573
|
+
"xid": entry["xid"],
|
|
574
|
+
"count": entry["count"],
|
|
575
|
+
"skills": sorted(entry["skills"]),
|
|
576
|
+
"runs": sorted(entry["runs"]),
|
|
577
|
+
}
|
|
578
|
+
)
|
|
579
|
+
rows.sort(key=lambda item: (-int(item["count"]), str(item["xid"])))
|
|
580
|
+
return rows
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _missing_information_ranking(runs: list[DashboardRun]) -> list[dict[str, object]]:
|
|
584
|
+
ranking: dict[str, dict[str, object]] = {}
|
|
585
|
+
for run in runs:
|
|
586
|
+
for item in run.missing_information:
|
|
587
|
+
code = item["code"]
|
|
588
|
+
entry = ranking.setdefault(
|
|
589
|
+
code,
|
|
590
|
+
{
|
|
591
|
+
"code": code,
|
|
592
|
+
"label": item["label"],
|
|
593
|
+
"detail": item["detail"],
|
|
594
|
+
"count": 0,
|
|
595
|
+
"skills": set(),
|
|
596
|
+
"runs": [],
|
|
597
|
+
},
|
|
598
|
+
)
|
|
599
|
+
entry["count"] = int(entry["count"]) + 1
|
|
600
|
+
assert isinstance(entry["skills"], set)
|
|
601
|
+
assert isinstance(entry["runs"], list)
|
|
602
|
+
entry["skills"].add(run.skill_id)
|
|
603
|
+
entry["runs"].append(run.path)
|
|
604
|
+
rows = [
|
|
605
|
+
{
|
|
606
|
+
**entry,
|
|
607
|
+
"skills": sorted(entry["skills"]),
|
|
608
|
+
"runs": sorted(entry["runs"]),
|
|
609
|
+
}
|
|
610
|
+
for entry in ranking.values()
|
|
611
|
+
]
|
|
612
|
+
rows.sort(key=lambda item: (-int(item["count"]), str(item["code"])))
|
|
613
|
+
return rows
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def build_payload(
|
|
617
|
+
root: Path,
|
|
618
|
+
sessions_dir: Path,
|
|
619
|
+
mcp_audit_log: Path | None = None,
|
|
620
|
+
) -> dict[str, object]:
|
|
621
|
+
audit_path = (mcp_audit_log or (root / "work" / "mcp" / "xid_audit.jsonl")).resolve()
|
|
622
|
+
mcp_events_by_run, audit_errors = _load_mcp_audit(audit_path)
|
|
623
|
+
runs = collect_runs(root, sessions_dir, mcp_events_by_run, audit_errors)
|
|
624
|
+
return {
|
|
625
|
+
"root": str(root.resolve()),
|
|
626
|
+
"sessions_dir": str(sessions_dir.resolve()),
|
|
627
|
+
"mcp_audit_log": str(audit_path),
|
|
628
|
+
"audit_errors": audit_errors,
|
|
629
|
+
"summary": _summary(runs),
|
|
630
|
+
"unused_xid_ranking": _unused_xid_ranking(runs),
|
|
631
|
+
"missing_information_ranking": _missing_information_ranking(runs),
|
|
632
|
+
"runs": [run.to_dict() for run in runs],
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _json_response(handler: BaseHTTPRequestHandler, payload: object, status: int = 200) -> None:
|
|
637
|
+
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
|
638
|
+
handler.send_response(status)
|
|
639
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
640
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
641
|
+
handler.end_headers()
|
|
642
|
+
handler.wfile.write(body)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _html_page(payload: dict[str, object]) -> str:
|
|
646
|
+
summary = payload["summary"]
|
|
647
|
+
runs = payload["runs"]
|
|
648
|
+
assert isinstance(summary, dict)
|
|
649
|
+
assert isinstance(runs, list)
|
|
650
|
+
cards = "".join(
|
|
651
|
+
f"<div class='metric'><span>{html.escape(str(label))}</span><strong>{value}</strong></div>"
|
|
652
|
+
for label, value in [
|
|
653
|
+
("Skill runs", summary["runs"]),
|
|
654
|
+
("Closed", summary["closed"]),
|
|
655
|
+
("Blocked", summary["blocked"]),
|
|
656
|
+
("Open", summary["open"]),
|
|
657
|
+
("Unknowns", summary["unknowns"]),
|
|
658
|
+
("Risks", summary["risks"]),
|
|
659
|
+
("Handoffs", summary["handoffs"]),
|
|
660
|
+
("Used XIDs", summary["used_xids"]),
|
|
661
|
+
("Unused XIDs", summary["unused_xids"]),
|
|
662
|
+
("Runs missing info", summary["runs_with_missing_information"]),
|
|
663
|
+
("Missing info items", summary["missing_information"]),
|
|
664
|
+
]
|
|
665
|
+
)
|
|
666
|
+
overview_rows = "\n".join(_overview_row(run) for run in runs[:30])
|
|
667
|
+
attention_rows = "\n".join(_attention_card(run) for run in runs if isinstance(run, dict) and run.get("status") == "blocked")
|
|
668
|
+
closure_rows = "\n".join(_closure_card(run) for run in runs)
|
|
669
|
+
evidence_rows = "\n".join(_evidence_card(run) for run in runs if _has_observed_records(run))
|
|
670
|
+
handoff_rows = "\n".join(_handoff_card(run) for run in runs if _has_handoff_records(run))
|
|
671
|
+
xid_rows = "\n".join(_xid_usage_card(run) for run in runs if _has_xid_records(run))
|
|
672
|
+
unused_xid_rows = _unused_xid_ranking_table(payload.get("unused_xid_ranking", []))
|
|
673
|
+
missing_information_rows = _missing_information_ranking_table(payload.get("missing_information_ranking", []))
|
|
674
|
+
missing_information_cards = "\n".join(
|
|
675
|
+
_missing_information_card(run)
|
|
676
|
+
for run in runs
|
|
677
|
+
if isinstance(run, dict) and run.get("missing_information")
|
|
678
|
+
)
|
|
679
|
+
empty = "<section class='empty'>No Skill run logs found under the configured sessions directory.</section>"
|
|
680
|
+
if not runs:
|
|
681
|
+
overview_rows = empty
|
|
682
|
+
if not attention_rows:
|
|
683
|
+
attention_rows = "<section class='empty'>No blocked Skill runs.</section>"
|
|
684
|
+
if not evidence_rows:
|
|
685
|
+
evidence_rows = "<section class='empty'>No output, evidence, or check artifacts recorded.</section>"
|
|
686
|
+
if not handoff_rows:
|
|
687
|
+
handoff_rows = "<section class='empty'>No handoff, unknown, risk, or judgment records.</section>"
|
|
688
|
+
if not xid_rows:
|
|
689
|
+
xid_rows = "<section class='empty'>No XID usage records found in Skill run logs.</section>"
|
|
690
|
+
if not missing_information_cards:
|
|
691
|
+
missing_information_cards = "<section class='empty'>No missing tuning information detected.</section>"
|
|
692
|
+
return f"""<!doctype html>
|
|
693
|
+
<html lang="en">
|
|
694
|
+
<head>
|
|
695
|
+
<meta charset="utf-8">
|
|
696
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
697
|
+
<title>Skill Run Observation Dashboard</title>
|
|
698
|
+
<style>
|
|
699
|
+
:root {{
|
|
700
|
+
color-scheme: light;
|
|
701
|
+
--bg: #f6f8fb;
|
|
702
|
+
--panel: #ffffff;
|
|
703
|
+
--ink: #172033;
|
|
704
|
+
--muted: #5b6577;
|
|
705
|
+
--line: #d6deea;
|
|
706
|
+
--blue: #2563eb;
|
|
707
|
+
--green: #15803d;
|
|
708
|
+
--amber: #b45309;
|
|
709
|
+
--red: #be123c;
|
|
710
|
+
}}
|
|
711
|
+
* {{ box-sizing: border-box; }}
|
|
712
|
+
body {{
|
|
713
|
+
margin: 0;
|
|
714
|
+
font-family: "BIZ UDPGothic", "Meiryo", "Segoe UI", sans-serif;
|
|
715
|
+
background: var(--bg);
|
|
716
|
+
color: var(--ink);
|
|
717
|
+
}}
|
|
718
|
+
header {{
|
|
719
|
+
padding: 28px 32px 18px;
|
|
720
|
+
border-bottom: 1px solid var(--line);
|
|
721
|
+
background: var(--panel);
|
|
722
|
+
position: sticky;
|
|
723
|
+
top: 0;
|
|
724
|
+
z-index: 2;
|
|
725
|
+
}}
|
|
726
|
+
h1 {{ margin: 0 0 8px; font-size: 28px; letter-spacing: 0; }}
|
|
727
|
+
.sub {{ margin: 0; color: var(--muted); font-size: 14px; }}
|
|
728
|
+
main {{ padding: 24px 32px 48px; max-width: 1400px; margin: 0 auto; }}
|
|
729
|
+
.metrics {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; }}
|
|
730
|
+
.tabs {{
|
|
731
|
+
display: flex;
|
|
732
|
+
flex-wrap: wrap;
|
|
733
|
+
gap: 8px;
|
|
734
|
+
margin: 0 0 18px;
|
|
735
|
+
}}
|
|
736
|
+
.tab {{
|
|
737
|
+
appearance: none;
|
|
738
|
+
border: 1px solid var(--line);
|
|
739
|
+
border-radius: 8px;
|
|
740
|
+
background: var(--panel);
|
|
741
|
+
color: var(--muted);
|
|
742
|
+
cursor: pointer;
|
|
743
|
+
font: inherit;
|
|
744
|
+
font-size: 14px;
|
|
745
|
+
padding: 9px 12px;
|
|
746
|
+
}}
|
|
747
|
+
.tab.active {{
|
|
748
|
+
border-color: var(--blue);
|
|
749
|
+
color: var(--blue);
|
|
750
|
+
background: #eaf1fb;
|
|
751
|
+
font-weight: 700;
|
|
752
|
+
}}
|
|
753
|
+
.panel {{ display: none; }}
|
|
754
|
+
.panel.active {{ display: block; }}
|
|
755
|
+
.metric {{
|
|
756
|
+
background: var(--panel);
|
|
757
|
+
border: 1px solid var(--line);
|
|
758
|
+
border-radius: 8px;
|
|
759
|
+
padding: 14px 16px;
|
|
760
|
+
}}
|
|
761
|
+
.metric span {{ display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; }}
|
|
762
|
+
.metric strong {{ font-size: 26px; }}
|
|
763
|
+
.category-note {{ color: var(--muted); margin: 0 0 14px; }}
|
|
764
|
+
.run {{
|
|
765
|
+
background: var(--panel);
|
|
766
|
+
border: 1px solid var(--line);
|
|
767
|
+
border-left: 6px solid var(--blue);
|
|
768
|
+
border-radius: 8px;
|
|
769
|
+
padding: 18px;
|
|
770
|
+
margin: 14px 0;
|
|
771
|
+
}}
|
|
772
|
+
.run.closed {{ border-left-color: var(--green); }}
|
|
773
|
+
.run.blocked {{ border-left-color: var(--red); }}
|
|
774
|
+
.run.open {{ border-left-color: var(--amber); }}
|
|
775
|
+
.run-head {{ display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; }}
|
|
776
|
+
.run h2 {{ margin: 0; font-size: 20px; }}
|
|
777
|
+
.path {{ color: var(--muted); font-size: 13px; margin-top: 4px; word-break: break-all; }}
|
|
778
|
+
.badge {{ display: inline-flex; align-items: center; border-radius: 999px; padding: 4px 10px; font-size: 12px; font-weight: 700; background: #eaf1fb; color: var(--blue); }}
|
|
779
|
+
.badge.closed {{ background: #e8f5ee; color: var(--green); }}
|
|
780
|
+
.badge.blocked {{ background: #fde8ef; color: var(--red); }}
|
|
781
|
+
.badge.open {{ background: #fff3df; color: var(--amber); }}
|
|
782
|
+
.table {{
|
|
783
|
+
width: 100%;
|
|
784
|
+
border-collapse: collapse;
|
|
785
|
+
background: var(--panel);
|
|
786
|
+
border: 1px solid var(--line);
|
|
787
|
+
border-radius: 8px;
|
|
788
|
+
overflow: hidden;
|
|
789
|
+
display: table;
|
|
790
|
+
}}
|
|
791
|
+
.table th, .table td {{
|
|
792
|
+
border-bottom: 1px solid var(--line);
|
|
793
|
+
padding: 10px 12px;
|
|
794
|
+
text-align: left;
|
|
795
|
+
vertical-align: top;
|
|
796
|
+
font-size: 13px;
|
|
797
|
+
}}
|
|
798
|
+
.table th {{ color: var(--muted); background: #fbfcff; font-weight: 700; }}
|
|
799
|
+
.table tr:last-child td {{ border-bottom: 0; }}
|
|
800
|
+
.grid {{ display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr); gap: 14px; margin-top: 14px; }}
|
|
801
|
+
.box {{ border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #fbfcff; }}
|
|
802
|
+
.box h3 {{ margin: 0 0 8px; font-size: 14px; }}
|
|
803
|
+
.kv {{ display: flex; flex-wrap: wrap; gap: 8px; }}
|
|
804
|
+
.pill {{ border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; color: var(--muted); background: white; font-size: 12px; }}
|
|
805
|
+
ul {{ margin: 8px 0 0; padding-left: 20px; }}
|
|
806
|
+
li {{ margin: 3px 0; }}
|
|
807
|
+
.empty {{ background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 24px; color: var(--muted); }}
|
|
808
|
+
@media (max-width: 780px) {{
|
|
809
|
+
header, main {{ padding-left: 16px; padding-right: 16px; }}
|
|
810
|
+
.grid {{ grid-template-columns: 1fr; }}
|
|
811
|
+
}}
|
|
812
|
+
</style>
|
|
813
|
+
</head>
|
|
814
|
+
<body>
|
|
815
|
+
<header>
|
|
816
|
+
<h1>Skill Run Observation Dashboard</h1>
|
|
817
|
+
<p class="sub">Root: {html.escape(str(payload["root"]))} | Sessions: {html.escape(str(payload["sessions_dir"]))} | <a href="/api/runs">JSON</a></p>
|
|
818
|
+
</header>
|
|
819
|
+
<main>
|
|
820
|
+
<nav class="tabs" aria-label="Dashboard categories">
|
|
821
|
+
<button class="tab active" data-panel="overview">Overview</button>
|
|
822
|
+
<button class="tab" data-panel="attention">Attention</button>
|
|
823
|
+
<button class="tab" data-panel="closure">Closure</button>
|
|
824
|
+
<button class="tab" data-panel="evidence">Evidence</button>
|
|
825
|
+
<button class="tab" data-panel="handoff">Handoff</button>
|
|
826
|
+
<button class="tab" data-panel="xids">XID Usage</button>
|
|
827
|
+
<button class="tab" data-panel="missing-information">Missing Information</button>
|
|
828
|
+
</nav>
|
|
829
|
+
<section id="overview" class="panel active">
|
|
830
|
+
<section class="metrics">{cards}</section>
|
|
831
|
+
<p class="category-note">Recent Skill runs and aggregate status. Detailed records are split into the other categories.</p>
|
|
832
|
+
<table class="table">
|
|
833
|
+
<thead><tr><th>Skill</th><th>Status</th><th>Closure</th><th>Updated</th><th>Log</th></tr></thead>
|
|
834
|
+
<tbody>{overview_rows}</tbody>
|
|
835
|
+
</table>
|
|
836
|
+
</section>
|
|
837
|
+
<section id="attention" class="panel">
|
|
838
|
+
<p class="category-note">Runs that need action before they can be treated as closed.</p>
|
|
839
|
+
{attention_rows}
|
|
840
|
+
</section>
|
|
841
|
+
<section id="closure" class="panel">
|
|
842
|
+
<p class="category-note">Runtime phase, closure gate, and quality gate state.</p>
|
|
843
|
+
{closure_rows}
|
|
844
|
+
</section>
|
|
845
|
+
<section id="evidence" class="panel">
|
|
846
|
+
<p class="category-note">Outputs, evidence artifacts, and quality-check artifacts recorded by Skill runs.</p>
|
|
847
|
+
{evidence_rows}
|
|
848
|
+
</section>
|
|
849
|
+
<section id="handoff" class="panel">
|
|
850
|
+
<p class="category-note">Handoff records plus unknown, risk, and judgment records that affect continuity.</p>
|
|
851
|
+
{handoff_rows}
|
|
852
|
+
</section>
|
|
853
|
+
<section id="xids" class="panel">
|
|
854
|
+
<p class="category-note">Base and local XIDs selected or used by each Skill run, plus available XIDs that were not used.</p>
|
|
855
|
+
<div class="box"><h3>Unused XID Ranking</h3>{unused_xid_rows}</div>
|
|
856
|
+
{xid_rows}
|
|
857
|
+
</section>
|
|
858
|
+
<section id="missing-information" class="panel">
|
|
859
|
+
<p class="category-note">Information required to correlate Skill execution, MCP access, Knowledge application, and downstream feedback.</p>
|
|
860
|
+
<div class="box"><h3>Missing Information Ranking</h3>{missing_information_rows}</div>
|
|
861
|
+
{missing_information_cards}
|
|
862
|
+
</section>
|
|
863
|
+
</main>
|
|
864
|
+
<script>
|
|
865
|
+
const tabs = Array.from(document.querySelectorAll(".tab"));
|
|
866
|
+
const panels = Array.from(document.querySelectorAll(".panel"));
|
|
867
|
+
function showPanel(id) {{
|
|
868
|
+
tabs.forEach((tab) => tab.classList.toggle("active", tab.dataset.panel === id));
|
|
869
|
+
panels.forEach((panel) => panel.classList.toggle("active", panel.id === id));
|
|
870
|
+
}}
|
|
871
|
+
tabs.forEach((tab) => tab.addEventListener("click", () => showPanel(tab.dataset.panel)));
|
|
872
|
+
</script>
|
|
873
|
+
</body>
|
|
874
|
+
</html>"""
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _base_run_parts(run: object) -> tuple[str, str, str, str, dict, dict, list]:
|
|
878
|
+
assert isinstance(run, dict)
|
|
879
|
+
status = html.escape(str(run["status"]))
|
|
880
|
+
skill_id = html.escape(str(run["skill_id"]))
|
|
881
|
+
path = html.escape(str(run["path"]))
|
|
882
|
+
mtime = html.escape(str(run["mtime"]))
|
|
883
|
+
sections = run.get("sections") if isinstance(run.get("sections"), dict) else {}
|
|
884
|
+
counts = run.get("counts") if isinstance(run.get("counts"), dict) else {}
|
|
885
|
+
blockers = run.get("blockers") if isinstance(run.get("blockers"), list) else []
|
|
886
|
+
return status, skill_id, path, mtime, sections, counts, blockers
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _overview_row(run: object) -> str:
|
|
890
|
+
status, skill_id, path, mtime, _, _, _ = _base_run_parts(run)
|
|
891
|
+
closure = html.escape(str(run["closure_status"])) if isinstance(run, dict) else ""
|
|
892
|
+
return (
|
|
893
|
+
"<tr>"
|
|
894
|
+
f"<td>{skill_id}</td>"
|
|
895
|
+
f"<td><span class='badge {status}'>{status}</span></td>"
|
|
896
|
+
f"<td>{closure}</td>"
|
|
897
|
+
f"<td>{mtime}</td>"
|
|
898
|
+
f"<td class='path'>{path}</td>"
|
|
899
|
+
"</tr>"
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _attention_card(run: object) -> str:
|
|
904
|
+
status, skill_id, path, mtime, _, _, blockers = _base_run_parts(run)
|
|
905
|
+
blocker_html = "<ul>" + "".join(f"<li>{html.escape(str(item))}</li>" for item in blockers[:16]) + "</ul>"
|
|
906
|
+
return _category_card(
|
|
907
|
+
status=status,
|
|
908
|
+
skill_id=skill_id,
|
|
909
|
+
path=path,
|
|
910
|
+
mtime=mtime,
|
|
911
|
+
body=f"<div class='box'><h3>Blockers</h3>{blocker_html}</div>",
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _closure_card(run: object) -> str:
|
|
916
|
+
status, skill_id, path, mtime, sections, _, _ = _base_run_parts(run)
|
|
917
|
+
phase_pills = "".join(
|
|
918
|
+
f"<span class='pill'>{html.escape(str(name))}: {html.escape(str(value))}</span>"
|
|
919
|
+
for name, value in sections.items()
|
|
920
|
+
)
|
|
921
|
+
closure = html.escape(str(run["closure_status"])) if isinstance(run, dict) else ""
|
|
922
|
+
quality = html.escape(str(run["quality_status"])) if isinstance(run, dict) else ""
|
|
923
|
+
required = html.escape(str(run["quality_required"])) if isinstance(run, dict) else ""
|
|
924
|
+
body = (
|
|
925
|
+
f"<div class='box'><h3>Runtime State</h3><div class='kv'>{phase_pills}</div></div>"
|
|
926
|
+
f"<div class='box'><h3>Closure</h3><div class='kv'><span class='pill'>closure: {closure}</span>"
|
|
927
|
+
f"<span class='pill'>quality: {quality}</span><span class='pill'>quality required: {required}</span></div></div>"
|
|
928
|
+
)
|
|
929
|
+
return _category_card(status=status, skill_id=skill_id, path=path, mtime=mtime, body=body)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _evidence_card(run: object) -> str:
|
|
933
|
+
status, skill_id, path, mtime, _, counts, _ = _base_run_parts(run)
|
|
934
|
+
count_pills = "".join(
|
|
935
|
+
f"<span class='pill'>{html.escape(str(name))}: {html.escape(str(counts.get(name, 0)))}</span>"
|
|
936
|
+
for name in ("outputs", "evidence", "checks", "artifacts")
|
|
937
|
+
)
|
|
938
|
+
artifacts = run.get("artifacts") if isinstance(run, dict) and isinstance(run.get("artifacts"), list) else []
|
|
939
|
+
artifact_items = "".join(
|
|
940
|
+
"<li>"
|
|
941
|
+
f"{html.escape(str(item.get('artifact_id', '-')))} "
|
|
942
|
+
f"{html.escape(str(item.get('kind', '-')))} "
|
|
943
|
+
f"{html.escape(str(item.get('status', '-')))}: "
|
|
944
|
+
f"{html.escape(str(item.get('target', '-')))}"
|
|
945
|
+
"</li>"
|
|
946
|
+
for item in artifacts[:8]
|
|
947
|
+
if isinstance(item, dict) and item.get("kind") in {"output", "evidence", "check"}
|
|
948
|
+
)
|
|
949
|
+
if not artifact_items:
|
|
950
|
+
artifact_items = "<li>No output/evidence/check artifacts.</li>"
|
|
951
|
+
body = (
|
|
952
|
+
f"<div class='box'><h3>Artifact Counts</h3><div class='kv'>{count_pills}</div></div>"
|
|
953
|
+
f"<div class='box'><h3>Recent Records</h3><ul>{artifact_items}</ul></div>"
|
|
954
|
+
)
|
|
955
|
+
return _category_card(status=status, skill_id=skill_id, path=path, mtime=mtime, body=body)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _handoff_card(run: object) -> str:
|
|
959
|
+
status, skill_id, path, mtime, _, counts, _ = _base_run_parts(run)
|
|
960
|
+
count_pills = "".join(
|
|
961
|
+
f"<span class='pill'>{html.escape(str(name))}: {html.escape(str(counts.get(name, 0)))}</span>"
|
|
962
|
+
for name in ("handoffs", "unknowns", "risks", "judgments")
|
|
963
|
+
)
|
|
964
|
+
artifacts = run.get("artifacts") if isinstance(run, dict) and isinstance(run.get("artifacts"), list) else []
|
|
965
|
+
concerns = run.get("concerns") if isinstance(run, dict) and isinstance(run.get("concerns"), list) else []
|
|
966
|
+
handoff_items = "".join(
|
|
967
|
+
f"<li>{html.escape(str(item.get('artifact_id', '-')))}: {html.escape(str(item.get('target', '-')))}</li>"
|
|
968
|
+
for item in artifacts[:8]
|
|
969
|
+
if isinstance(item, dict) and item.get("kind") == "handoff"
|
|
970
|
+
)
|
|
971
|
+
concern_items = "".join(
|
|
972
|
+
"<li>"
|
|
973
|
+
f"{html.escape(str(item.get('concern_id', '-')))} "
|
|
974
|
+
f"{html.escape(str(item.get('kind', '-')))} "
|
|
975
|
+
f"{html.escape(str(item.get('status', '-')))}: "
|
|
976
|
+
f"{html.escape(str(item.get('text', '-')))}"
|
|
977
|
+
"</li>"
|
|
978
|
+
for item in concerns[:8]
|
|
979
|
+
if isinstance(item, dict)
|
|
980
|
+
)
|
|
981
|
+
if not handoff_items:
|
|
982
|
+
handoff_items = "<li>No handoff artifacts.</li>"
|
|
983
|
+
if not concern_items:
|
|
984
|
+
concern_items = "<li>No unknown/risk/judgment records.</li>"
|
|
985
|
+
body = (
|
|
986
|
+
f"<div class='box'><h3>Continuity Counts</h3><div class='kv'>{count_pills}</div></div>"
|
|
987
|
+
f"<div class='box'><h3>Handoff</h3><ul>{handoff_items}</ul></div>"
|
|
988
|
+
f"<div class='box'><h3>Unknown / Risk / Judgment</h3><ul>{concern_items}</ul></div>"
|
|
989
|
+
)
|
|
990
|
+
return _category_card(status=status, skill_id=skill_id, path=path, mtime=mtime, body=body)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _xid_usage_card(run: object) -> str:
|
|
994
|
+
status, skill_id, path, mtime, _, _, _ = _base_run_parts(run)
|
|
995
|
+
assert isinstance(run, dict)
|
|
996
|
+
used_xids = run.get("used_xids") if isinstance(run.get("used_xids"), list) else []
|
|
997
|
+
selected_xids = run.get("selected_xids") if isinstance(run.get("selected_xids"), list) else []
|
|
998
|
+
queried_xids = run.get("queried_xids") if isinstance(run.get("queried_xids"), list) else []
|
|
999
|
+
loaded_xids = run.get("loaded_xids") if isinstance(run.get("loaded_xids"), list) else []
|
|
1000
|
+
available_xids = run.get("available_xids") if isinstance(run.get("available_xids"), list) else []
|
|
1001
|
+
unused_xids = run.get("unused_xids") if isinstance(run.get("unused_xids"), list) else []
|
|
1002
|
+
used = _xid_pills(used_xids, empty="No used XIDs recorded.")
|
|
1003
|
+
selected = _xid_pills(selected_xids, empty="No selected knowledge XIDs.")
|
|
1004
|
+
queried = _xid_pills(queried_xids, empty="No MCP-resolved XIDs.")
|
|
1005
|
+
loaded = _xid_pills(loaded_xids, empty="No client-loaded XIDs.")
|
|
1006
|
+
available = _xid_pills(available_xids, empty="No available base/local knowledge XIDs.")
|
|
1007
|
+
unused = _xid_pills(unused_xids, empty="No unused available base/local XIDs.")
|
|
1008
|
+
body = (
|
|
1009
|
+
f"<div class='box'><h3>Used XIDs</h3>{used}</div>"
|
|
1010
|
+
f"<div class='box'><h3>Selected Knowledge Inputs</h3>{selected}</div>"
|
|
1011
|
+
f"<div class='box'><h3>MCP-resolved XIDs</h3>{queried}</div>"
|
|
1012
|
+
f"<div class='box'><h3>Client-loaded XIDs</h3>{loaded}</div>"
|
|
1013
|
+
f"<div class='box'><h3>Available Knowledge XIDs (base/local)</h3>{available}</div>"
|
|
1014
|
+
f"<div class='box'><h3>Unused Available XIDs (base/local)</h3>{unused}</div>"
|
|
1015
|
+
)
|
|
1016
|
+
return _category_card(status=status, skill_id=skill_id, path=path, mtime=mtime, body=body)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _missing_information_card(run: object) -> str:
|
|
1020
|
+
status, skill_id, path, mtime, _, _, _ = _base_run_parts(run)
|
|
1021
|
+
assert isinstance(run, dict)
|
|
1022
|
+
values = run.get("missing_information") if isinstance(run.get("missing_information"), list) else []
|
|
1023
|
+
items = "".join(
|
|
1024
|
+
"<li>"
|
|
1025
|
+
f"<strong>{html.escape(str(item.get('label', item.get('code', '-'))))}</strong>: "
|
|
1026
|
+
f"{html.escape(str(item.get('detail', '-')))}"
|
|
1027
|
+
"</li>"
|
|
1028
|
+
for item in values
|
|
1029
|
+
if isinstance(item, dict)
|
|
1030
|
+
)
|
|
1031
|
+
body = f"<div class='box'><h3>Missing Tuning Information</h3><ul>{items}</ul></div>"
|
|
1032
|
+
return _category_card(status=status, skill_id=skill_id, path=path, mtime=mtime, body=body)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _xid_pills(values: object, *, empty: str) -> str:
|
|
1036
|
+
if not isinstance(values, list) or not values:
|
|
1037
|
+
return f"<p class='path'>{html.escape(empty)}</p>"
|
|
1038
|
+
return "<div class='kv'>" + "".join(
|
|
1039
|
+
f"<span class='pill'>{html.escape(str(value))}</span>" for value in values[:20]
|
|
1040
|
+
) + "</div>"
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _unused_xid_ranking_table(rows: object) -> str:
|
|
1044
|
+
if not isinstance(rows, list) or not rows:
|
|
1045
|
+
return "<p class='path'>No unused available base/local XIDs were found.</p>"
|
|
1046
|
+
body = "".join(_unused_xid_row(row) for row in rows[:50] if isinstance(row, dict))
|
|
1047
|
+
return (
|
|
1048
|
+
"<table class='table'>"
|
|
1049
|
+
"<thead><tr><th>XID</th><th>Unused Count</th><th>Skills</th><th>Runs</th></tr></thead>"
|
|
1050
|
+
f"<tbody>{body}</tbody></table>"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _missing_information_ranking_table(rows: object) -> str:
|
|
1055
|
+
if not isinstance(rows, list) or not rows:
|
|
1056
|
+
return "<p class='path'>No missing tuning information was detected.</p>"
|
|
1057
|
+
body = "".join(
|
|
1058
|
+
"<tr>"
|
|
1059
|
+
f"<td>{html.escape(str(row.get('label', row.get('code', ''))))}</td>"
|
|
1060
|
+
f"<td>{html.escape(str(row.get('count', '')))}</td>"
|
|
1061
|
+
f"<td>{html.escape(', '.join(str(value) for value in row.get('skills', [])[:8]))}</td>"
|
|
1062
|
+
f"<td>{html.escape(str(row.get('detail', '')))}</td>"
|
|
1063
|
+
"</tr>"
|
|
1064
|
+
for row in rows[:50]
|
|
1065
|
+
if isinstance(row, dict)
|
|
1066
|
+
)
|
|
1067
|
+
return (
|
|
1068
|
+
"<table class='table'>"
|
|
1069
|
+
"<thead><tr><th>Information</th><th>Runs</th><th>Skills</th><th>Reason</th></tr></thead>"
|
|
1070
|
+
f"<tbody>{body}</tbody></table>"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _unused_xid_row(row: dict[str, object]) -> str:
|
|
1075
|
+
xid = html.escape(str(row.get("xid", "")))
|
|
1076
|
+
count = html.escape(str(row.get("count", "")))
|
|
1077
|
+
skills_value = row.get("skills") if isinstance(row.get("skills"), list) else []
|
|
1078
|
+
runs_value = row.get("runs") if isinstance(row.get("runs"), list) else []
|
|
1079
|
+
skills = ", ".join(html.escape(str(value)) for value in skills_value[:8])
|
|
1080
|
+
runs = "<br>".join(html.escape(str(value)) for value in runs_value[:5])
|
|
1081
|
+
return f"<tr><td>{xid}</td><td>{count}</td><td>{skills}</td><td class='path'>{runs}</td></tr>"
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _category_card(*, status: str, skill_id: str, path: str, mtime: str, body: str) -> str:
|
|
1085
|
+
return f"""
|
|
1086
|
+
<section class="run {status}">
|
|
1087
|
+
<div class="run-head">
|
|
1088
|
+
<div>
|
|
1089
|
+
<h2>{skill_id}</h2>
|
|
1090
|
+
<div class="path">{path}</div>
|
|
1091
|
+
<div class="path">updated {mtime}</div>
|
|
1092
|
+
</div>
|
|
1093
|
+
<span class="badge {status}">{status}</span>
|
|
1094
|
+
</div>
|
|
1095
|
+
<div class="grid">{body}</div>
|
|
1096
|
+
</section>"""
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _has_observed_records(run: object) -> bool:
|
|
1100
|
+
if not isinstance(run, dict) or not isinstance(run.get("counts"), dict):
|
|
1101
|
+
return False
|
|
1102
|
+
counts = run["counts"]
|
|
1103
|
+
return any(int(counts.get(name, 0)) > 0 for name in ("outputs", "evidence", "checks"))
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _has_handoff_records(run: object) -> bool:
|
|
1107
|
+
if not isinstance(run, dict) or not isinstance(run.get("counts"), dict):
|
|
1108
|
+
return False
|
|
1109
|
+
counts = run["counts"]
|
|
1110
|
+
return any(int(counts.get(name, 0)) > 0 for name in ("handoffs", "unknowns", "risks", "judgments"))
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _has_xid_records(run: object) -> bool:
|
|
1114
|
+
if not isinstance(run, dict):
|
|
1115
|
+
return False
|
|
1116
|
+
return any(
|
|
1117
|
+
isinstance(run.get(name), list) and len(run.get(name)) > 0
|
|
1118
|
+
for name in (
|
|
1119
|
+
"available_xids",
|
|
1120
|
+
"selected_xids",
|
|
1121
|
+
"queried_xids",
|
|
1122
|
+
"loaded_xids",
|
|
1123
|
+
"used_xids",
|
|
1124
|
+
"unused_xids",
|
|
1125
|
+
)
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
class DashboardServer(ThreadingHTTPServer):
|
|
1130
|
+
def __init__(
|
|
1131
|
+
self,
|
|
1132
|
+
server_address: tuple[str, int],
|
|
1133
|
+
handler,
|
|
1134
|
+
*,
|
|
1135
|
+
root: Path,
|
|
1136
|
+
sessions_dir: Path,
|
|
1137
|
+
mcp_audit_log: Path,
|
|
1138
|
+
) -> None:
|
|
1139
|
+
super().__init__(server_address, handler)
|
|
1140
|
+
self.root = root
|
|
1141
|
+
self.sessions_dir = sessions_dir
|
|
1142
|
+
self.mcp_audit_log = mcp_audit_log
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
class DashboardHandler(BaseHTTPRequestHandler):
|
|
1146
|
+
server: DashboardServer
|
|
1147
|
+
|
|
1148
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
1149
|
+
sys.stderr.write("dashboard: " + format % args + "\n")
|
|
1150
|
+
|
|
1151
|
+
def do_GET(self) -> None:
|
|
1152
|
+
parsed = urlparse(self.path)
|
|
1153
|
+
if parsed.path == "/api/runs":
|
|
1154
|
+
payload = build_payload(self.server.root, self.server.sessions_dir, self.server.mcp_audit_log)
|
|
1155
|
+
_json_response(self, payload)
|
|
1156
|
+
return
|
|
1157
|
+
if parsed.path == "/healthz":
|
|
1158
|
+
_json_response(self, {"ok": True})
|
|
1159
|
+
return
|
|
1160
|
+
if parsed.path in {"/", "/index.html"}:
|
|
1161
|
+
payload = build_payload(self.server.root, self.server.sessions_dir, self.server.mcp_audit_log)
|
|
1162
|
+
body = _html_page(payload).encode("utf-8")
|
|
1163
|
+
self.send_response(200)
|
|
1164
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
1165
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1166
|
+
self.end_headers()
|
|
1167
|
+
self.wfile.write(body)
|
|
1168
|
+
return
|
|
1169
|
+
_json_response(self, {"error": "not found"}, status=404)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def serve_dashboard(
|
|
1173
|
+
*,
|
|
1174
|
+
root: Path,
|
|
1175
|
+
sessions_dir: Path,
|
|
1176
|
+
mcp_audit_log: Path,
|
|
1177
|
+
host: str,
|
|
1178
|
+
port: int,
|
|
1179
|
+
open_browser: bool,
|
|
1180
|
+
) -> None:
|
|
1181
|
+
server = DashboardServer(
|
|
1182
|
+
(host, port),
|
|
1183
|
+
DashboardHandler,
|
|
1184
|
+
root=root.resolve(),
|
|
1185
|
+
sessions_dir=sessions_dir.resolve(),
|
|
1186
|
+
mcp_audit_log=mcp_audit_log.resolve(),
|
|
1187
|
+
)
|
|
1188
|
+
url = f"http://{host}:{server.server_port}/"
|
|
1189
|
+
print(f"Skill Run Observation Dashboard: {url}")
|
|
1190
|
+
print(f"root: {root.resolve()}")
|
|
1191
|
+
print(f"sessions: {sessions_dir.resolve()}")
|
|
1192
|
+
if open_browser:
|
|
1193
|
+
webbrowser.open(url)
|
|
1194
|
+
try:
|
|
1195
|
+
server.serve_forever()
|
|
1196
|
+
except KeyboardInterrupt:
|
|
1197
|
+
print("\nDashboard stopped.")
|
|
1198
|
+
finally:
|
|
1199
|
+
server.server_close()
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
1203
|
+
root = Path(args.root).resolve()
|
|
1204
|
+
sessions_dir = Path(args.sessions_dir).resolve() if args.sessions_dir else root / "work" / "sessions"
|
|
1205
|
+
mcp_audit_log = Path(args.mcp_audit_log).resolve() if args.mcp_audit_log else root / "work" / "mcp" / "xid_audit.jsonl"
|
|
1206
|
+
if args.dashboard_cmd == "data":
|
|
1207
|
+
payload = build_payload(root, sessions_dir, mcp_audit_log)
|
|
1208
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1209
|
+
return 0
|
|
1210
|
+
if args.dashboard_cmd == "serve":
|
|
1211
|
+
serve_dashboard(
|
|
1212
|
+
root=root,
|
|
1213
|
+
sessions_dir=sessions_dir,
|
|
1214
|
+
mcp_audit_log=mcp_audit_log,
|
|
1215
|
+
host=args.host,
|
|
1216
|
+
port=args.port,
|
|
1217
|
+
open_browser=args.open_browser,
|
|
1218
|
+
)
|
|
1219
|
+
return 0
|
|
1220
|
+
return 2
|