Skip to content

Commit aa1984a

Browse files
committed
Harden runtime recovery browser gate
1 parent b099f2a commit aa1984a

4 files changed

Lines changed: 122 additions & 27 deletions

File tree

‎docs/skills/architecture/execution-engine.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ whenToUse: 새 엔진 구현, capability 추가 (execute/interrupt/variables/fil
2020
- 셀 실행/검증은 장기적으로 `cell-call` 계약으로 수렴한다. `execute-reactive`, `check-exercise`는 하위 호환 및 세부 구현 도구다.
2121
- editor의 직접 실행 버튼도 kernel execute로 바로 들어가지 않는다. `editor/src/lib/notebookRuntime.ts`가 세션 패키지 목록을 확인하고, 누락 패키지를 세션 `packages/install` capability의 uv 경로로 준비한 뒤 `execute` 또는 `execute-reactive`를 호출한다.
2222
- `editor-runtime-preflight` gate는 이 순서가 `packages-check → packages-install → cell-call` 의미와 어긋나지 않는지 확인한다.
23+
- runtime 복구 UX는 실패 종류를 섞지 않는다. 패키지 준비 실패는 `라이브러리 준비 실패`로 표시하고 cell-call을 막는다. 셀 실행 자체가 실패하면 결과 박스 안에 `셀 실행 실패`와 마지막 오류 줄 확인, 코드 수정 후 재실행 가능 안내를 함께 보여준다.
24+
- `runtime-recovery-browser` gate는 실제 제품 화면에서 package failure와 cell execution failure가 각각 셀 근처 복구 UX로 보이는지 확인한다.
2325
- kernel 실행 결과를 HTTP, websocket, tool payload로 바꾸는 기준은 `src/codaro/kernel/executionPayload.py`에 둔다.
2426

2527
## 관련

‎editor/src/components/app/appPrimitives.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ export function ExecutionOutput({ result }: { result: ExecutionResult }) {
9090
const hasError = result.status === "error" || Boolean(result.stderr);
9191
const output = result.stderr || result.stdout || stringifyData(result.data) || "출력 없음";
9292
return (
93-
<div className={cn("rounded-md bg-muted/30 p-3", hasError && "bg-destructive/10")}>
93+
<div
94+
className={cn("rounded-md bg-muted/30 p-3", hasError && "bg-destructive/10")}
95+
data-execution-output="true"
96+
data-execution-output-status={hasError ? "error" : "ok"}
97+
>
9498
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
9599
<span className="font-medium uppercase text-muted-foreground">출력</span>
96100
<Badge variant={hasError ? "destructive" : "outline"}>
@@ -100,6 +104,20 @@ export function ExecutionOutput({ result }: { result: ExecutionResult }) {
100104
<ScrollArea className="max-h-72">
101105
<pre className="whitespace-pre-wrap font-mono text-sm leading-6">{output}</pre>
102106
</ScrollArea>
107+
{hasError ? (
108+
<div
109+
className="mt-3 flex gap-2 rounded-md border border-destructive/25 bg-background/70 px-3 py-2 text-xs leading-5"
110+
data-runtime-recovery="cell-error"
111+
>
112+
<XCircle className="mt-0.5 size-3.5 shrink-0 text-destructive" />
113+
<div className="min-w-0">
114+
<div className="font-medium text-foreground">셀 실행 실패</div>
115+
<div className="text-muted-foreground">
116+
오류 메시지의 마지막 줄부터 확인하고, import·변수명·입력값을 고친 뒤 이 셀을 다시 실행할 수 있습니다.
117+
</div>
118+
</div>
119+
</div>
120+
) : null}
103121
</div>
104122
);
105123
}

‎tests/verifyRuntimeRecoveryContract.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ def evaluate(self) -> dict[str, Any]:
8181
"sessionPackageInstall",
8282
),
8383
),
84+
Evidence(
85+
"editor-runtime-cell-error-recovery",
86+
"editor/src/components/app/appPrimitives.tsx",
87+
(
88+
"data-runtime-recovery=\"cell-error\"",
89+
"오류 메시지의 마지막 줄부터 확인",
90+
"다시 실행할 수 있습니다",
91+
),
92+
),
93+
Evidence(
94+
"runtime-recovery-browser",
95+
"tests/verifyRuntimeRecoveryPlaywright.py",
96+
(
97+
"package-failure-recovery-ok",
98+
"cell-failure-recovery-ok",
99+
"cell-call package-failure",
100+
"cell-call cell-failure",
101+
),
102+
),
84103
Evidence(
85104
"teacher-policy-recovery",
86105
"src/codaro/ai/teacher/toolPolicy.py",

‎tests/verifyRuntimeRecoveryPlaywright.py‎

Lines changed: 82 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424

2525
ROOT = Path(__file__).resolve().parents[1]
2626
STORAGE_KEY = "codaro-custom-curricula"
27-
FIXTURE_TITLE = "Runtime recovery browser fixture"
27+
PACKAGE_FAILURE_TITLE = "Runtime package recovery fixture"
28+
CELL_FAILURE_TITLE = "Runtime cell failure fixture"
2829
MISSING_PACKAGE = "codaro_missing_runtime_pkg"
30+
CELL_FAILURE_SYMBOL = "undefined_runtime_value"
2931

3032

3133
def main(argv: list[str] | None = None) -> int:
@@ -51,17 +53,25 @@ def main(argv: list[str] | None = None) -> int:
5153
cli = PlaywrightCli(cliPath=cliPath, cwd=tempRoot, session=session)
5254
cli.run("open", url)
5355
cli.run("resize", "1280", "900")
54-
cli.run("localstorage-set", STORAGE_KEY, json.dumps([customCurriculumEntry()], ensure_ascii=False))
56+
cli.run("localstorage-set", STORAGE_KEY, json.dumps([
57+
customCurriculumEntry(PACKAGE_FAILURE_TITLE, customCurriculumDocument("package-failure")),
58+
customCurriculumEntry(CELL_FAILURE_TITLE, customCurriculumDocument("cell-failure")),
59+
], ensure_ascii=False))
5560
cli.run("reload")
56-
cli.waitEval(jsTextPresent(FIXTURE_TITLE), "custom runtime recovery curriculum")
57-
cli.eval(jsClickText(FIXTURE_TITLE))
61+
cli.waitEval(jsTextPresent(PACKAGE_FAILURE_TITLE), "custom runtime recovery curriculum")
62+
cli.eval(jsClickText(PACKAGE_FAILURE_TITLE))
5863
cli.waitEval(jsTextPresent("셀을 실행하면 결과와 오류가 여기에 표시됩니다."), "idle result recovery copy")
5964
idle = cli.eval(jsAssertIdleRuntimeRecovery())
6065
cli.eval(jsClickFirstRunButton())
6166
cli.waitEval(jsTextPresent("라이브러리 준비 실패"), "package install failure notice")
6267
failure = cli.eval(jsAssertPackageFailureRecovery())
68+
cli.eval(jsClickText(CELL_FAILURE_TITLE))
69+
cli.waitEval(jsTextPresent("셀을 실행하면 결과와 오류가 여기에 표시됩니다."), "cell failure idle result copy")
70+
cli.eval(jsClickFirstRunButton())
71+
cli.waitEval(jsTextPresent("셀 실행 실패"), "cell execution failure recovery")
72+
cellFailure = cli.eval(jsAssertCellFailureRecovery())
6373
api.assertExpectedCalls()
64-
print(f"ok: runtime recovery browser verified {idle} {failure}")
74+
print(f"ok: runtime recovery browser verified {idle} {failure} {cellFailure}")
6575
return 0
6676
except (VerificationError, PlaywrightCliError) as exc:
6777
print(f"FAIL: {exc}", file=sys.stderr)
@@ -108,11 +118,12 @@ def assertExpectedCalls(self) -> None:
108118
"POST /api/kernel/create",
109119
"GET /api/kernel/session-runtime-recovery/packages/list packages-check",
110120
f"POST /api/kernel/session-runtime-recovery/packages/install packages-install {MISSING_PACKAGE}",
121+
"POST /api/kernel/session-runtime-recovery/execute cell-call cell-failure",
111122
}
112123
missing = sorted(required - set(self.calls))
113124
if missing:
114125
raise VerificationError("runtime recovery API calls missing: " + ", ".join(missing))
115-
if any("execute" in call for call in self.calls):
126+
if any("cell-call package-failure" in call for call in self.calls):
116127
raise VerificationError("cell-call endpoint should not run after package install failure")
117128

118129
def _handler(self):
@@ -138,9 +149,9 @@ def do_GET(self):
138149
"learningPaths": {},
139150
})
140151
elif path.startswith("/api/curriculum/contents/"):
141-
self._sendJson({"category": "30days", "categoryName": "파이썬 기초", "contents": [{"contentId": "runtime-recovery", "title": FIXTURE_TITLE}]})
152+
self._sendJson({"category": "30days", "categoryName": "파이썬 기초", "contents": [{"contentId": "runtime-recovery", "title": PACKAGE_FAILURE_TITLE}]})
142153
elif path.startswith("/api/curriculum/content/"):
143-
self._sendJson({"document": customCurriculumDocument(), "solutions": {}})
154+
self._sendJson({"document": customCurriculumDocument("package-failure"), "solutions": {}})
144155
elif path == "/api/ai/tools":
145156
self._sendJson({"groups": [], "lanes": [], "tools": [], "grouped": {}, "byLane": {}})
146157
elif path == "/api/ai/profile":
@@ -177,8 +188,22 @@ def do_POST(self):
177188
"skipped": False,
178189
})
179190
elif path.endswith("/execute"):
180-
owner.calls.append(f"POST {path} cell-call")
181-
self._sendJson({"type": "text", "status": "error", "data": "should not execute", "blockId": payload.get("blockId")})
191+
code = str(payload.get("code") or "")
192+
if MISSING_PACKAGE in code:
193+
owner.calls.append(f"POST {path} cell-call package-failure")
194+
else:
195+
owner.calls.append(f"POST {path} cell-call cell-failure")
196+
self._sendJson({
197+
"type": "error",
198+
"status": "error",
199+
"data": f"NameError: name '{CELL_FAILURE_SYMBOL}' is not defined",
200+
"stdout": "",
201+
"stderr": f"Traceback (most recent call last):\nNameError: name '{CELL_FAILURE_SYMBOL}' is not defined",
202+
"blockId": payload.get("blockId"),
203+
"variables": [],
204+
"stateDelta": {"added": [], "updated": [], "removed": []},
205+
"executionCount": 1,
206+
})
182207
else:
183208
self._sendJson({"error": f"unhandled {path}"}, status=404)
184209

@@ -298,6 +323,21 @@ def jsAssertPackageFailureRecovery() -> str:
298323
""")
299324

300325

326+
def jsAssertCellFailureRecovery() -> str:
327+
return compactJs(f"""
328+
(() => {{
329+
const text = document.body.innerText;
330+
const required = ['셀 실행 실패', '{CELL_FAILURE_SYMBOL}', '오류 메시지의 마지막 줄부터 확인', '다시 실행할 수 있습니다'];
331+
const missing = required.filter((item) => !text.includes(item));
332+
if (missing.length) throw new Error('cell failure recovery missing: ' + missing.join(', '));
333+
if (!document.querySelector('[data-runtime-recovery="cell-error"]')) {{
334+
throw new Error('cell failure recovery hint missing near execution output');
335+
}}
336+
return 'cell-failure-recovery-ok';
337+
}})()
338+
""")
339+
340+
301341
def debugPageState(cli: PlaywrightCli) -> str:
302342
try:
303343
return cli.eval("document.body.innerText.slice(0, 2000)")
@@ -309,17 +349,17 @@ def compactJs(source: str) -> str:
309349
return " ".join(line.strip() for line in source.strip().splitlines() if line.strip())
310350

311351

312-
def customCurriculumEntry() -> dict[str, Any]:
352+
def customCurriculumEntry(title: str, document: dict[str, Any]) -> dict[str, Any]:
313353
return {
314-
"id": "custom-runtime-recovery",
315-
"title": FIXTURE_TITLE,
316-
"document": customCurriculumDocument(),
354+
"id": f"custom-{title.lower().replace(' ', '-')}",
355+
"title": title,
356+
"document": document,
317357
"createdAt": 1_700_000_000_000,
318358
}
319359

320360

321-
def customCurriculumDocument() -> dict[str, Any]:
322-
document, _solutions = yamlToDocument(yaml.safe_load(structuredLessonYaml()), "runtime-recovery", "package-failure")
361+
def customCurriculumDocument(caseId: str) -> dict[str, Any]:
362+
document, _solutions = yamlToDocument(yaml.safe_load(structuredLessonYaml(caseId)), "runtime-recovery", caseId)
323363
payload = document.model_dump(mode="json")
324364
payload["id"] = "runtime-recovery-document"
325365
return compactDocumentForBrowserStorage(payload)
@@ -416,14 +456,27 @@ def compactSectionContract(contract: dict[str, Any]) -> dict[str, Any]:
416456
}
417457

418458

419-
def structuredLessonYaml() -> str:
459+
def structuredLessonYaml(caseId: str) -> str:
460+
if caseId == "cell-failure":
461+
title = CELL_FAILURE_TITLE
462+
packages = " []"
463+
snippet = f"print({CELL_FAILURE_SYMBOL})"
464+
starterCode = f"print({CELL_FAILURE_SYMBOL})"
465+
subtitle = "셀 실행 오류를 결과 근처에서 확인"
466+
why = "실행 오류가 package failure와 섞이지 않아야 코드를 고쳐 다시 실행하기 쉽다."
467+
else:
468+
title = PACKAGE_FAILURE_TITLE
469+
packages = f"\n - {MISSING_PACKAGE}"
470+
snippet = f"import {MISSING_PACKAGE}\nprint(\"ready\")"
471+
starterCode = f"import {MISSING_PACKAGE}\nprint(\"ready\")"
472+
subtitle = "패키지 설치 실패를 셀 근처에서 확인"
473+
why = "설치 실패가 cell-call로 번지지 않아야 다시 실행 경로가 분명하다."
420474
return f"""
421475
meta:
422-
title: {FIXTURE_TITLE}
476+
title: {title}
423477
audience: local dogfood learner
424478
difficulty: beginner
425-
packages:
426-
- {MISSING_PACKAGE}
479+
packages:{packages}
427480
intro:
428481
direction: runtime recovery path
429482
benefits:
@@ -433,21 +486,19 @@ def structuredLessonYaml() -> str:
433486
sections:
434487
- id: recovery
435488
title: Runtime 복구
436-
subtitle: 패키지 설치 실패를 셀 근처에서 확인
489+
subtitle: {subtitle}
437490
goal: runtime preflight가 실행 전에 필요한 패키지를 확인한다.
438-
why: 설치 실패가 cell-call로 번지지 않아야 다시 실행 경로가 분명하다.
491+
why: {why}
439492
explanation: 실행 전 package preflight가 필요한 패키지를 확인하고 실패를 사용자 문장으로 돌려준다.
440493
tips:
441494
- 실패한 패키지 이름과 설치 경로를 먼저 본다.
442495
- 설치 실패 뒤에는 셀 실행을 진행하지 않는다.
443496
snippet: |
444-
import {MISSING_PACKAGE}
445-
print("ready")
497+
{indentYamlBlock(snippet, 6)}
446498
exercise:
447499
prompt: 실패를 확인하기 위해 실습 셀을 실행하세요.
448500
starterCode: |
449-
import {MISSING_PACKAGE}
450-
print("ready")
501+
{indentYamlBlock(starterCode, 8)}
451502
solution: |
452503
print("fallback")
453504
check:
@@ -459,5 +510,10 @@ def structuredLessonYaml() -> str:
459510
"""
460511

461512

513+
def indentYamlBlock(value: str, spaces: int) -> str:
514+
prefix = " " * spaces
515+
return "\n".join(f"{prefix}{line}" for line in value.splitlines())
516+
517+
462518
if __name__ == "__main__":
463519
raise SystemExit(main())

0 commit comments

Comments
 (0)