Skip to content

Commit 34c22f7

Browse files
Kasper Jungeclaude
authored andcommitted
refactor: deduplicate response construction in primitives API endpoints
Extract _response_from_marker() to centralise the read-parse-respond pattern that was duplicated across _primitive_to_response, update_primitive, and create_primitive. Also includes the in-progress check test endpoint and related UI additions that were already in the working tree. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b643a63 commit 34c22f7

4 files changed

Lines changed: 221 additions & 33 deletions

File tree

src/ralphify/ui/api/primitives.py

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import base64
55
import shutil
6+
import time
67
from pathlib import Path
78

89
from fastapi import APIRouter, HTTPException
@@ -15,11 +16,11 @@
1516
parse_frontmatter,
1617
serialize_frontmatter,
1718
)
18-
from ralphify.checks import discover_checks
19+
from ralphify.checks import discover_checks, run_check
1920
from ralphify.contexts import discover_contexts
2021
from ralphify.instructions import discover_instructions
2122
from ralphify.prompts import discover_prompts
22-
from ralphify.ui.models import PrimitiveResponse, PrimitiveUpdate
23+
from ralphify.ui.models import CheckTestResponse, PrimitiveResponse, PrimitiveUpdate
2324

2425
router = APIRouter()
2526

@@ -51,24 +52,35 @@ def _resolve_kind(kind: str) -> tuple:
5152
raise HTTPException(status_code=400, detail=f"Unknown kind: {kind}")
5253

5354

54-
def _primitive_to_response(prim, kind: str) -> PrimitiveResponse:
55-
"""Convert a discovered primitive to a response model."""
56-
marker = _KIND_MAP[kind][1]
57-
marker_file = prim.path / marker
58-
if marker_file.exists():
59-
text = marker_file.read_text()
60-
fm, body = parse_frontmatter(text)
61-
else:
62-
fm, body = {}, ""
55+
def _response_from_marker(marker_file: Path, kind: str, name: str) -> PrimitiveResponse:
56+
"""Read a marker file, parse its frontmatter, and build a response.
57+
58+
Centralises the read-parse-respond pattern used by list/get (via
59+
``_primitive_to_response``), update, and create endpoints so the
60+
response construction logic lives in one place.
61+
"""
62+
fm, body = parse_frontmatter(marker_file.read_text())
6363
return PrimitiveResponse(
6464
kind=kind,
65-
name=prim.name,
66-
enabled=prim.enabled,
65+
name=name,
66+
enabled=fm.get("enabled", True),
6767
content=body,
6868
frontmatter=fm,
6969
)
7070

7171

72+
def _primitive_to_response(prim, kind: str) -> PrimitiveResponse:
73+
"""Convert a discovered primitive to a response model."""
74+
marker = _KIND_MAP[kind][1]
75+
marker_file = prim.path / marker
76+
if not marker_file.exists():
77+
return PrimitiveResponse(
78+
kind=kind, name=prim.name, enabled=prim.enabled,
79+
content="", frontmatter={},
80+
)
81+
return _response_from_marker(marker_file, kind, prim.name)
82+
83+
7284
@router.get(
7385
"/projects/{project_dir}/primitives",
7486
response_model=list[PrimitiveResponse],
@@ -112,17 +124,7 @@ async def update_primitive(
112124
raise HTTPException(status_code=404, detail="Primitive not found")
113125

114126
marker_file.write_text(serialize_frontmatter(body.frontmatter or {}, body.content))
115-
116-
# Re-read to return updated state
117-
text = marker_file.read_text()
118-
fm, content = parse_frontmatter(text)
119-
return PrimitiveResponse(
120-
kind=kind,
121-
name=name,
122-
enabled=fm.get("enabled", True),
123-
content=content,
124-
frontmatter=fm,
125-
)
127+
return _response_from_marker(marker_file, kind, name)
126128

127129

128130
@router.post(
@@ -154,15 +156,7 @@ async def create_primitive(
154156
marker_file = prim_dir / marker
155157

156158
marker_file.write_text(serialize_frontmatter(body.frontmatter or {}, body.content))
157-
158-
fm, content = parse_frontmatter(marker_file.read_text())
159-
return PrimitiveResponse(
160-
kind=kind,
161-
name=name,
162-
enabled=fm.get("enabled", True),
163-
content=content,
164-
frontmatter=fm,
165-
)
159+
return _response_from_marker(marker_file, kind, name)
166160

167161

168162
@router.delete("/projects/{project_dir}/primitives/{kind}/{name}", status_code=204)
@@ -174,3 +168,25 @@ async def delete_primitive(project_dir: str, kind: str, name: str) -> None:
174168
if not prim_dir.exists():
175169
raise HTTPException(status_code=404, detail="Primitive not found")
176170
shutil.rmtree(prim_dir)
171+
172+
173+
@router.post(
174+
"/projects/{project_dir}/primitives/checks/{name}/test",
175+
response_model=CheckTestResponse,
176+
)
177+
async def test_check(project_dir: str, name: str) -> CheckTestResponse:
178+
"""Run a single check and return the result."""
179+
root = _decode_project_dir(project_dir)
180+
for check in discover_checks(root):
181+
if check.name == name:
182+
start = time.monotonic()
183+
result = run_check(check, root)
184+
duration = time.monotonic() - start
185+
return CheckTestResponse(
186+
passed=result.passed,
187+
exit_code=result.exit_code,
188+
output=result.output,
189+
timed_out=result.timed_out,
190+
duration=round(duration, 2),
191+
)
192+
raise HTTPException(status_code=404, detail="Check not found")

src/ralphify/ui/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@ class PrimitiveResponse(BaseModel):
4545
class PrimitiveUpdate(BaseModel):
4646
content: str
4747
frontmatter: dict | None = None
48+
49+
50+
class CheckTestResponse(BaseModel):
51+
passed: bool
52+
exit_code: int
53+
output: str
54+
timed_out: bool
55+
duration: float

src/ralphify/ui/static/dashboard.css

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,106 @@ body {
14621462
color: #dc2626;
14631463
}
14641464

1465+
/* ── Check test ───────────────────────────────────────────────── */
1466+
1467+
.check-test-btn {
1468+
gap: 6px;
1469+
font-weight: 500;
1470+
border-color: var(--primary-border);
1471+
color: var(--primary);
1472+
}
1473+
1474+
.check-test-btn:hover:not(:disabled) {
1475+
background: var(--primary-light);
1476+
border-color: var(--primary);
1477+
}
1478+
1479+
.check-test-btn.testing {
1480+
color: var(--text-secondary);
1481+
cursor: wait;
1482+
}
1483+
1484+
.check-test-result {
1485+
margin-top: 16px;
1486+
border-radius: var(--radius);
1487+
border: 1px solid;
1488+
overflow: hidden;
1489+
animation: fade-in 0.2s ease;
1490+
}
1491+
1492+
.check-test-result.passed {
1493+
border-color: rgba(74, 222, 128, 0.3);
1494+
background: rgba(74, 222, 128, 0.04);
1495+
}
1496+
1497+
.check-test-result.failed {
1498+
border-color: rgba(248, 113, 113, 0.3);
1499+
background: rgba(248, 113, 113, 0.04);
1500+
}
1501+
1502+
.check-test-result-header {
1503+
display: flex;
1504+
align-items: center;
1505+
gap: 12px;
1506+
padding: 10px 14px;
1507+
}
1508+
1509+
.check-test-result-status {
1510+
display: flex;
1511+
align-items: center;
1512+
gap: 6px;
1513+
font-size: 13px;
1514+
font-weight: 600;
1515+
}
1516+
1517+
.check-test-result.passed .check-test-result-status {
1518+
color: #16a34a;
1519+
}
1520+
1521+
.check-test-result.failed .check-test-result-status {
1522+
color: #dc2626;
1523+
}
1524+
1525+
.check-test-result-meta {
1526+
flex: 1;
1527+
text-align: right;
1528+
font-size: 12px;
1529+
font-family: var(--font-mono);
1530+
color: var(--text-secondary);
1531+
}
1532+
1533+
.check-test-dismiss {
1534+
background: none;
1535+
border: none;
1536+
padding: 4px;
1537+
cursor: pointer;
1538+
color: var(--text-muted);
1539+
border-radius: 4px;
1540+
display: flex;
1541+
align-items: center;
1542+
}
1543+
1544+
.check-test-dismiss:hover {
1545+
color: var(--text);
1546+
background: rgba(0, 0, 0, 0.05);
1547+
}
1548+
1549+
.check-test-output {
1550+
margin: 0;
1551+
padding: 12px 14px;
1552+
font-family: var(--font-mono);
1553+
font-size: 12px;
1554+
line-height: 1.6;
1555+
color: var(--text);
1556+
background: rgba(0, 0, 0, 0.03);
1557+
border-top: 1px solid rgba(0, 0, 0, 0.06);
1558+
overflow-x: auto;
1559+
max-height: 300px;
1560+
overflow-y: auto;
1561+
white-space: pre-wrap;
1562+
word-break: break-word;
1563+
}
1564+
14651565
/* ── History panel ───────────────────────────────────────────────── */
14661566

14671567
.history-view-header {

src/ralphify/ui/static/dashboard.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,8 +1043,11 @@ function PrimEditForm({ primitive, kind, meta, onBack, onSaved }) {
10431043
);
10441044
const [saving, setSaving] = useState(false);
10451045
const [deleting, setDeleting] = useState(false);
1046+
const [testing, setTesting] = useState(false);
1047+
const [testResult, setTestResult] = useState(null);
10461048

10471049
const hasCommand = kind === 'checks' || kind === 'contexts';
1050+
const isCheck = kind === 'checks';
10481051

10491052
const hasChanges = content !== primitive.content ||
10501053
description !== (primitive.frontmatter?.description || '') ||
@@ -1086,6 +1089,18 @@ function PrimEditForm({ primitive, kind, meta, onBack, onSaved }) {
10861089
setDeleting(false);
10871090
}
10881091

1092+
async function handleTest() {
1093+
setTesting(true);
1094+
setTestResult(null);
1095+
try {
1096+
const result = await api('POST', `/projects/${btoa('.')}/primitives/checks/${primitive.name}/test`);
1097+
setTestResult(result);
1098+
} catch (e) {
1099+
setTestResult({ passed: false, exit_code: -1, output: e.message || 'Test failed', timed_out: false, duration: 0 });
1100+
}
1101+
setTesting(false);
1102+
}
1103+
10891104
return html`
10901105
<div class="prim-editor">
10911106
<div class="prim-editor-header">
@@ -1100,6 +1115,20 @@ function PrimEditForm({ primitive, kind, meta, onBack, onSaved }) {
11001115
<${KindIcon} kind=${kind} size=${18} />
11011116
</div>
11021117
<h2>${primitive.name}</h2>
1118+
${isCheck && html`
1119+
<div style="flex: 1"></div>
1120+
<button class="btn btn-sm check-test-btn ${testing ? 'testing' : ''}" onClick=${handleTest} disabled=${testing || !command.trim()}>
1121+
${testing ? html`
1122+
<div class="btn-spinner"></div>
1123+
Running...
1124+
` : html`
1125+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
1126+
<polygon points="5 3 19 12 5 21 5 3"/>
1127+
</svg>
1128+
Test
1129+
`}
1130+
</button>
1131+
`}
11031132
</div>
11041133
</div>
11051134
<div class="prim-editor-body">
@@ -1149,6 +1178,41 @@ function PrimEditForm({ primitive, kind, meta, onBack, onSaved }) {
11491178
rows=${hasCommand ? '8' : '14'}
11501179
placeholder="Write the content here..."></textarea>
11511180
</div>
1181+
${testResult && html`
1182+
<div class="check-test-result ${testResult.passed ? 'passed' : 'failed'}">
1183+
<div class="check-test-result-header">
1184+
<div class="check-test-result-status">
1185+
${testResult.passed ? html`
1186+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
1187+
<polyline points="20 6 9 17 4 12"/>
1188+
</svg>
1189+
Passed
1190+
` : testResult.timed_out ? html`
1191+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1192+
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
1193+
</svg>
1194+
Timed out
1195+
` : html`
1196+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
1197+
<path d="M18 6L6 18"/><path d="M6 6l12 12"/>
1198+
</svg>
1199+
Failed (exit ${testResult.exit_code})
1200+
`}
1201+
</div>
1202+
<div class="check-test-result-meta">
1203+
${testResult.duration > 0 ? `${testResult.duration}s` : ''}
1204+
</div>
1205+
<button class="check-test-dismiss" onClick=${() => setTestResult(null)} title="Dismiss">
1206+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
1207+
<path d="M18 6L6 18"/><path d="M6 6l12 12"/>
1208+
</svg>
1209+
</button>
1210+
</div>
1211+
${testResult.output && testResult.output.trim() && html`
1212+
<pre class="check-test-output">${testResult.output.trim()}</pre>
1213+
`}
1214+
</div>
1215+
`}
11521216
</div>
11531217
<div class="prim-editor-actions">
11541218
<button class="btn btn-danger-outline" onClick=${handleDelete} disabled=${deleting}>

0 commit comments

Comments
 (0)