Skip to content

Commit e862593

Browse files
committed
pyproc 파일 세계 브라우저 검증 추가
1 parent 838997d commit e862593

12 files changed

Lines changed: 575 additions & 7 deletions

File tree

docs/skills/ops/foundation/testing-and-gates.md

Lines changed: 5 additions & 2 deletions
Large diffs are not rendered by default.

docs/skills/ops/product/service-candidate.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ runtime failure는 한 덩어리 오류가 아니다.
7070
- 각 vendor 파일의 `sha256-` integrity는 브라우저 `crypto.subtle.digest`로 다시 계산한다.
7171
- process worker payload가 실행 가능한 module 형태를 잃으면 실패해야 한다.
7272

73+
## 웹 파이썬 파일 세계
74+
75+
브라우저 티어가 로컬급 OS에 가까워지려면 셀 실행이 메모리 안 값만 남기는 것이 아니라 파일 세계에도 남아야 한다.
76+
77+
- `pyproc-runtime-fs-browser`는 editor build에서 실제 pyproc을 boot하고, `Runtime.fs`로 셀 소스와 실행 기록을 `/home/web/codaro`에 쓴다.
78+
- 첫 번째 셀의 실행 기록은 `output/test-runner/pyproc-runtime-fs-browser/pyproc-runtime-fs-report.json``runtimeFileSystem: Runtime.fs`, `pythonOpenShared: true`, source/run record path로 남는다.
79+
- 두 번째 셀은 Python `open()`으로 첫 번째 셀의 `/home/web/codaro/runs/*.json`을 읽어야 한다. JS `Runtime.fs`와 Python 파일 IO가 같은 파일 세계를 공유하지 못하면 실패한다.
80+
- 실행 결과 UI는 `data-runtime-artifacts`로 브라우저 FS 셀 소스와 실행 기록 경로를 보여준다.
81+
7382
## Provider/Teacher Loop
7483

7584
scripted provider만 통과하는 상태는 제품 품질 기준을 만족하지 못한다.
@@ -183,6 +192,7 @@ uv run python -X utf8 tests/run.py gate install-launcher-smoke
183192
uv run python -X utf8 tests/run.py gate runtime-recovery-contract
184193
uv run python -X utf8 tests/run.py gate runtime-recovery-browser
185194
uv run python -X utf8 tests/run.py gate pyproc-assets-browser
195+
uv run python -X utf8 tests/run.py gate pyproc-runtime-fs-browser
186196
uv run python -X utf8 tests/run.py gate curriculum-quality-matrix
187197
uv run python -X utf8 tests/run.py gate curriculum-top-tier-audit
188198
uv run python -X utf8 tests/run.py gate playwright-curriculum-runtime
@@ -192,8 +202,8 @@ uv run python -X utf8 tests/run.py gate landing-build
192202
uv run python -X utf8 tests/run.py gate launcher-test
193203
```
194204

195-
`diagnostic-summary-contract`는 local diagnostic summary의 category/action/redaction 계약을 고정한다. `architecture-boundary`는 report가 없는 빠른 집중 gate지만 `quality-cycle` command log path/size/freshness로 현재 커밋에서 실행됐는지 확인한다. `editor-build`와 `launcher-check`는 집중 확인 gate로 유지한다. 첫 사용자 완주 audit은 `dogfood-alpha-audit/dogfood-alpha-report.json`의 fresh 여부와 `gitHead`로 대조한다. 자동화 IDE audit은 `automation-ide-audit/automation-ide-report.json`의 fresh 여부와 `gitHead`로 대조한다. 진단/운영 표면은 `diagnostic-summary-contract/diagnostic-summary-report.json`의 fresh 여부와 `gitHead`로 대조한다. `quality-cycle`에서는 editor build 증거를 `learning-system-readiness`의 `learning-card-contract` probe, `pyproc-assets-browser/pyproc-assets-report.json`, `frontend-performance-budget`로 보고, `frontend-performance-budget/performance-report.json`의 fresh 여부와 `gitHead`도 sequence artifact evidence로 대조한다. 첫 사용자 화면은 `onboarding-browser/onboarding-report.json`의 fresh 여부와 `gitHead`로 대조하고, provider 설정 흐름은 `provider-settings-browser/provider-settings-report.json`의 fresh 여부와 `gitHead`로 대조한다. runtime 복구 UX는 `runtime-recovery-browser/runtime-recovery-report.json`의 fresh 여부와 `gitHead`로 대조한다. 학습 품질은 `curriculum-quality-matrix/curriculum-quality-report.json`, `curriculum-top-tier-audit/curriculum-top-tier-report.json`, `playwright-curriculum-runtime/playwright-curriculum-runtime-report.json`의 fresh 여부와 `gitHead`로 대조한다. launcher 설치/실행 smoke는 `install-launcher-smoke/install-launcher-report.json`의 fresh 여부와 `gitHead`로 대조한다. launcher check 증거를 `install-launcher-smoke`와 `launcher-test`로 본다. `teacher-eval`, `teacher-e2e`, `assistant-workloop-contract`, `editor-runtime-preflight`, `learning-card-contract`, `learning-card-browser`는 `learning-system-readiness`의 blocking probe로 실행된다.
205+
`diagnostic-summary-contract`는 local diagnostic summary의 category/action/redaction 계약을 고정한다. `architecture-boundary`는 report가 없는 빠른 집중 gate지만 `quality-cycle` command log path/size/freshness로 현재 커밋에서 실행됐는지 확인한다. `editor-build`와 `launcher-check`는 집중 확인 gate로 유지한다. 첫 사용자 완주 audit은 `dogfood-alpha-audit/dogfood-alpha-report.json`의 fresh 여부와 `gitHead`로 대조한다. 자동화 IDE audit은 `automation-ide-audit/automation-ide-report.json`의 fresh 여부와 `gitHead`로 대조한다. 진단/운영 표면은 `diagnostic-summary-contract/diagnostic-summary-report.json`의 fresh 여부와 `gitHead`로 대조한다. `quality-cycle`에서는 editor build 증거를 `learning-system-readiness`의 `learning-card-contract` probe, `pyproc-assets-browser/pyproc-assets-report.json`, `pyproc-runtime-fs-browser/pyproc-runtime-fs-report.json`, `frontend-performance-budget`로 보고, `frontend-performance-budget/performance-report.json`의 fresh 여부와 `gitHead`도 sequence artifact evidence로 대조한다. 첫 사용자 화면은 `onboarding-browser/onboarding-report.json`의 fresh 여부와 `gitHead`로 대조하고, provider 설정 흐름은 `provider-settings-browser/provider-settings-report.json`의 fresh 여부와 `gitHead`로 대조한다. runtime 복구 UX는 `runtime-recovery-browser/runtime-recovery-report.json`의 fresh 여부와 `gitHead`로 대조한다. 학습 품질은 `curriculum-quality-matrix/curriculum-quality-report.json`, `curriculum-top-tier-audit/curriculum-top-tier-report.json`, `playwright-curriculum-runtime/playwright-curriculum-runtime-report.json`의 fresh 여부와 `gitHead`로 대조한다. launcher 설치/실행 smoke는 `install-launcher-smoke/install-launcher-report.json`의 fresh 여부와 `gitHead`로 대조한다. launcher check 증거를 `install-launcher-smoke`와 `launcher-test`로 본다. `teacher-eval`, `teacher-e2e`, `assistant-workloop-contract`, `editor-runtime-preflight`, `learning-card-contract`, `learning-card-browser`는 `learning-system-readiness`의 blocking probe로 실행된다.
196206

197207
## 완료 판단
198208

199-
목표 완료 선언은 “잘 만들어졌다”는 품질 판단을 증명해야 한다. `quality-cycle`, `product-quality-audit`, `automation-ide-audit`, 개별 gate 결과가 tracked worktree clean 상태에서 최신 `output/test-runner/quality-cycle/sequence-summary.json`과 `output/test-runner/ai-live-smoke/live-smoke-report.json`을 현재 `gitHead`와 대조해 stale artifact가 아님을 확인해야 한다. 이 최종 artifact 검사는 22개 product gate 통과, 각 gate command log path/size/freshness와 실제 repo-local log 파일 존재/크기, `softFailureCount: 0`, live provider credential, clarification-before-provider, `resolve-learning-goal → search-curricula → compose-master-plan` 추천·조합, gap evidence 뒤 `packages-check → write-curriculum-yaml`, `packages-check → cell-call` exact sequence를 함께 본다. live provider credential이 없는 환경에서는 `ai-live-smoke`의 `live credential missing`과 quality-cycle `softFailureCount`를 증거로 남기되, 실제 provider 품질 판단은 credential이 있는 환경에서 다시 실행한 결과를 붙인다. `service-readiness-audit`는 이전 자동화를 위한 호환 alias일 뿐 완료 기준의 이름으로 쓰지 않는다.
209+
목표 완료 선언은 “잘 만들어졌다”는 품질 판단을 증명해야 한다. `quality-cycle`, `product-quality-audit`, `automation-ide-audit`, 개별 gate 결과가 tracked worktree clean 상태에서 최신 `output/test-runner/quality-cycle/sequence-summary.json`과 `output/test-runner/ai-live-smoke/live-smoke-report.json`을 현재 `gitHead`와 대조해 stale artifact가 아님을 확인해야 한다. 이 최종 artifact 검사는 23개 product gate 통과, 각 gate command log path/size/freshness와 실제 repo-local log 파일 존재/크기, `softFailureCount: 0`, live provider credential, clarification-before-provider, `resolve-learning-goal → search-curricula → compose-master-plan` 추천·조합, gap evidence 뒤 `packages-check → write-curriculum-yaml`, `packages-check → cell-call` exact sequence를 함께 본다. live provider credential이 없는 환경에서는 `ai-live-smoke`의 `live credential missing`과 quality-cycle `softFailureCount`를 증거로 남기되, 실제 provider 품질 판단은 credential이 있는 환경에서 다시 실행한 결과를 붙인다. `service-readiness-audit`는 이전 자동화를 위한 호환 alias일 뿐 완료 기준의 이름으로 쓰지 않는다.

editor/src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { loadSharePackCurriculum } from "@/lib/sharePackOperations";
3434
import { loadSystemDiagnosticExport } from "@/lib/systemDiagnostics";
3535
import { providerProfileReady } from "@/lib/providerProfile";
3636
import { WidgetSessionProvider } from "@/lib/widgetSession";
37+
import { installBrowserPythonRuntimeDiagnostics } from "@/lib/browserPythonRuntime";
3738
import {
3839
SidebarInset,
3940
SidebarProvider,
@@ -53,6 +54,12 @@ function App() {
5354
root.style.setProperty("--keyboard-height", `${viewportInsets.keyboardHeight}px`);
5455
}, [viewportInsets.isKeyboardOpen, viewportInsets.keyboardHeight]);
5556

57+
useEffect(() => {
58+
const params = new URLSearchParams(window.location.search);
59+
if (!params.has("codaroBrowserRuntimeDiagnostics")) return undefined;
60+
return installBrowserPythonRuntimeDiagnostics();
61+
}, []);
62+
5663
useEffect(() => {
5764
const handler = (event: Event) => {
5865
const detail = (event as CustomEvent<{ blockIds?: string[]; sessionId?: string }>).detail;

editor/src/components/app/appPrimitives.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,29 @@ export function ExecutionOutput({
138138
<pre className="whitespace-pre-wrap font-mono text-sm leading-6">{output}</pre>
139139
</ScrollArea>
140140
)}
141+
{result.artifacts?.length ? (
142+
<div
143+
className="mt-3 rounded-md border border-border/70 bg-background/70 px-3 py-2 text-xs leading-5"
144+
data-runtime-artifacts="true"
145+
>
146+
<div className="mb-1 font-medium text-foreground">{t("system.runtimeArtifacts")}</div>
147+
<div className="space-y-1">
148+
{result.artifacts.map((artifact) => (
149+
<div
150+
key={`${artifact.kind}:${artifact.path}`}
151+
className="min-w-0"
152+
data-runtime-artifact-kind={artifact.kind}
153+
>
154+
<span className="text-muted-foreground">{artifact.label}: </span>
155+
<span className="break-all font-mono text-[11px]">{artifact.path}</span>
156+
{artifact.detail ? (
157+
<span className="ml-1 text-muted-foreground">{artifact.detail}</span>
158+
) : null}
159+
</div>
160+
))}
161+
</div>
162+
</div>
163+
) : null}
141164
{hasError ? (
142165
<div
143166
className="mt-3 flex gap-2 rounded-md border border-destructive/25 bg-background/70 px-3 py-2 text-xs leading-5"

editor/src/lib/browserPythonRuntime.ts

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@
77
import type { ExecutionResult, VariableInfo } from "@/types";
88

99
type PyRuntime = {
10+
fs: PyRuntimeFileSystem;
1011
run(code: string): unknown;
1112
runAsync(code: string): Promise<unknown>;
1213
install(pkg: string): Promise<void>;
1314
loadPackages(pkgs: string | string[]): Promise<void>;
1415
};
1516

17+
type PyRuntimeFileSystem = {
18+
writeFile(path: string, data: string | Uint8Array, opts?: { encoding?: "utf8" | "binary" }): void;
19+
readFile(path: string, opts?: { encoding?: "utf8" | "binary" }): Uint8Array | string;
20+
mkdirTree(path: string): void;
21+
exists(path: string): boolean;
22+
};
23+
1624
type PyProcAssetIntegrity = {
1725
files?: { path: string; url: string; integrity: string; roles?: string[] }[];
1826
};
@@ -31,11 +39,17 @@ const stdoutLines: string[] = [];
3139
const stderrLines: string[] = [];
3240
let previousVariables = new Map<string, VariableInfo>();
3341
const attemptedPackages = new Set<string>();
42+
const browserFsRoot = "/home/web/codaro";
43+
const browserFsCellsDir = `${browserFsRoot}/cells`;
44+
const browserFsRunsDir = `${browserFsRoot}/runs`;
3445

3546
function assetIntegrityUrl(): string {
3647
const envUrl = import.meta.env.VITE_PYPROC_ASSET_INTEGRITY_URL;
3748
if (typeof envUrl === "string" && envUrl.trim()) return envUrl;
38-
return new URL("pyproc-assets.json", import.meta.env.BASE_URL || "/").pathname;
49+
const appBase = import.meta.env.BASE_URL || "/";
50+
const baseHref = new URL(appBase, window.location.origin).href;
51+
const manifestUrl = new URL("pyproc-assets.json", baseHref);
52+
return `${manifestUrl.pathname}${manifestUrl.search}${manifestUrl.hash}`;
3953
}
4054

4155
async function loadAssetIntegrity(): Promise<PyProcAssetIntegrity | null> {
@@ -162,6 +176,111 @@ function drainBuffers() {
162176
return { stdout, stderr };
163177
}
164178

179+
function browserSafeBlockName(blockId: string): string {
180+
const safe = blockId.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
181+
return safe || "cell";
182+
}
183+
184+
function ensureBrowserFileWorld(runtime: PyRuntime): void {
185+
runtime.fs.mkdirTree(browserFsCellsDir);
186+
runtime.fs.mkdirTree(browserFsRunsDir);
187+
}
188+
189+
function pythonBool(value: unknown): boolean {
190+
return value === true || String(value).toLowerCase() === "true";
191+
}
192+
193+
function pythonFileEquals(runtime: PyRuntime, path: string, expected: string): boolean {
194+
const code = [
195+
"import pathlib as _codaroPathlib",
196+
`_codaroPathlib.Path(${JSON.stringify(path)}).read_text(encoding="utf-8") == ${JSON.stringify(expected)}`,
197+
].join("\n");
198+
return pythonBool(runtime.run(code));
199+
}
200+
201+
function pythonRunRecordMatches(runtime: PyRuntime, path: string, blockId: string, status: string): boolean {
202+
const code = [
203+
"import json as _codaroJson",
204+
`_codaroRecord = _codaroJson.load(open(${JSON.stringify(path)}, encoding="utf-8"))`,
205+
`_codaroRecord.get("blockId") == ${JSON.stringify(blockId)} and _codaroRecord.get("status") == ${JSON.stringify(status)}`,
206+
].join("\n");
207+
return pythonBool(runtime.run(code));
208+
}
209+
210+
function writeBrowserCellSource(runtime: PyRuntime, blockId: string, code: string) {
211+
ensureBrowserFileWorld(runtime);
212+
const sourcePath = `${browserFsCellsDir}/${browserSafeBlockName(blockId)}.py`;
213+
runtime.fs.writeFile(sourcePath, code, { encoding: "utf8" });
214+
const pythonOpenVerified = pythonFileEquals(runtime, sourcePath, code);
215+
if (!pythonOpenVerified) {
216+
throw new Error(`browser Runtime.fs source mirror was not visible to Python open(): ${sourcePath}`);
217+
}
218+
return { sourcePath, pythonOpenVerified };
219+
}
220+
221+
function writeBrowserRunRecord(
222+
runtime: PyRuntime,
223+
record: {
224+
blockId: string;
225+
executionCount: number;
226+
status: string;
227+
stdout: string;
228+
stderr: string;
229+
resultRepr: string;
230+
sourcePath: string;
231+
},
232+
) {
233+
ensureBrowserFileWorld(runtime);
234+
const resultPath = `${browserFsRunsDir}/${browserSafeBlockName(record.blockId)}.json`;
235+
const payload = {
236+
...record,
237+
resultPath,
238+
completedAt: new Date().toISOString(),
239+
runtime: {
240+
tier: "browser",
241+
engine: "pyproc",
242+
fileSystem: "Runtime.fs",
243+
pythonOpenShared: true,
244+
},
245+
};
246+
const text = `${JSON.stringify(payload, null, 2)}\n`;
247+
runtime.fs.writeFile(resultPath, text, { encoding: "utf8" });
248+
runtime.fs.writeFile(`${browserFsRunsDir}/latest.json`, text, { encoding: "utf8" });
249+
const readBack = JSON.parse(String(runtime.fs.readFile(resultPath, { encoding: "utf8" }))) as { blockId?: string; status?: string };
250+
if (readBack.blockId !== record.blockId || readBack.status !== record.status) {
251+
throw new Error(`browser Runtime.fs run record readback mismatch: ${resultPath}`);
252+
}
253+
const pythonOpenVerified = pythonRunRecordMatches(runtime, resultPath, record.blockId, record.status);
254+
if (!pythonOpenVerified) {
255+
throw new Error(`browser Runtime.fs run record was not visible to Python open(): ${resultPath}`);
256+
}
257+
return { resultPath, latestPath: `${browserFsRunsDir}/latest.json`, pythonOpenVerified };
258+
}
259+
260+
function browserFileArtifacts(evidence: {
261+
sourcePath: string;
262+
resultPath: string;
263+
latestPath: string;
264+
pythonOpenVerified: boolean;
265+
}) {
266+
return [
267+
{
268+
kind: "browser-runtime-source-file",
269+
label: "브라우저 FS 셀 소스",
270+
path: evidence.sourcePath,
271+
detail: "pyproc Runtime.fs writeFile, Python open() 공유 확인",
272+
},
273+
{
274+
kind: "browser-runtime-run-record",
275+
label: "브라우저 FS 실행 기록",
276+
path: evidence.resultPath,
277+
detail: evidence.pythonOpenVerified
278+
? `latest: ${evidence.latestPath}, Python open() 확인`
279+
: `latest: ${evidence.latestPath}`,
280+
},
281+
];
282+
}
283+
165284
/** 셀 하나를 브라우저 WASM CPython에서 진짜 실행한다. */
166285
export async function executeBrowserBlock(
167286
blockId: string,
@@ -175,7 +294,11 @@ export async function executeBrowserBlock(
175294

176295
let resultRepr = "";
177296
let errorText = "";
297+
let sourcePath = "";
298+
let artifacts: ReturnType<typeof browserFileArtifacts> = [];
178299
try {
300+
const sourceEvidence = writeBrowserCellSource(runtime, blockId, code);
301+
sourcePath = sourceEvidence.sourcePath;
179302
const value = await runtime.runAsync(code);
180303
if (value !== undefined && value !== null) {
181304
resultRepr = String(value);
@@ -187,6 +310,24 @@ export async function executeBrowserBlock(
187310
}
188311

189312
const { stdout, stderr } = drainBuffers();
313+
const status = errorText ? "error" : "success";
314+
try {
315+
if (sourcePath) {
316+
const runEvidence = writeBrowserRunRecord(runtime, {
317+
blockId,
318+
executionCount,
319+
status,
320+
stdout,
321+
stderr: errorText ? (stderr ? `${stderr}\n${errorText}` : errorText) : stderr,
322+
resultRepr,
323+
sourcePath,
324+
});
325+
artifacts = browserFileArtifacts({ sourcePath, ...runEvidence });
326+
}
327+
} catch (error) {
328+
const fileError = error instanceof Error ? error.message : String(error);
329+
errorText = errorText ? `${errorText}\n${fileError}` : fileError;
330+
}
190331
const variables = errorText ? [] : collectVariables(runtime);
191332
const stateDelta = errorText ? { added: [], updated: [], removed: [] } : computeDelta(variables);
192333
const combinedStdout = resultRepr ? (stdout ? `${stdout}\n${resultRepr}` : resultRepr) : stdout;
@@ -201,6 +342,7 @@ export async function executeBrowserBlock(
201342
stateDelta,
202343
executionCount,
203344
status: errorText ? "error" : "success",
345+
artifacts,
204346
};
205347
}
206348

@@ -220,3 +362,36 @@ export async function runBrowserNotebook(
220362
}
221363
return { results, variables: lastVariables };
222364
}
365+
366+
type BrowserPythonRuntimeDiagnostics = {
367+
executeBlock: typeof executeBrowserBlock;
368+
isBooted: typeof isBrowserKernelBooted;
369+
readTextFile: (path: string) => Promise<string>;
370+
runNotebook: typeof runBrowserNotebook;
371+
};
372+
373+
declare global {
374+
interface Window {
375+
__codaroBrowserPythonDiagnostics?: BrowserPythonRuntimeDiagnostics;
376+
}
377+
}
378+
379+
export function installBrowserPythonRuntimeDiagnostics(): () => void {
380+
const previous = window.__codaroBrowserPythonDiagnostics;
381+
window.__codaroBrowserPythonDiagnostics = {
382+
executeBlock: executeBrowserBlock,
383+
isBooted: isBrowserKernelBooted,
384+
readTextFile: async (path: string) => {
385+
const runtime = await ensureRuntime();
386+
return String(runtime.fs.readFile(path, { encoding: "utf8" }));
387+
},
388+
runNotebook: runBrowserNotebook,
389+
};
390+
return () => {
391+
if (previous) {
392+
window.__codaroBrowserPythonDiagnostics = previous;
393+
} else {
394+
delete window.__codaroBrowserPythonDiagnostics;
395+
}
396+
};
397+
}

0 commit comments

Comments
 (0)