diff --git a/apps/api/app/contracts/g7_external_evidence.py b/apps/api/app/contracts/g7_external_evidence.py index 80b43f1..ee0cef1 100644 --- a/apps/api/app/contracts/g7_external_evidence.py +++ b/apps/api/app/contracts/g7_external_evidence.py @@ -32,7 +32,7 @@ class G7EvidenceProvenance(BaseModel): @model_validator(mode="after") def require_preregistered_analysis(self) -> "G7EvidenceProvenance": - if self.registered_at > self.held_out_labels_opened_at: + if self.registered_at >= self.held_out_labels_opened_at: raise ValueError("analysis protocol must precede held-out label access") return self @@ -62,7 +62,7 @@ class G7PowerPlan(BaseModel): required_held_out_participants: int = Field(ge=1) required_held_out_sessions: int = Field(ge=1) required_paired_axis_observations: int = Field(ge=3) - alpha: float = Field(gt=0.0, le=0.05) + alpha: Literal[0.05] = 0.05 target_power: float = Field(ge=0.8, lt=1.0) minimally_detectable_gain: float = Field(gt=0.0, le=1.0) planned_bootstrap_samples: Literal[10000] = 10000 @@ -91,10 +91,7 @@ class G7HumanAxisLabel(BaseModel): labeler_key: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$") score: float = Field(ge=0.0, le=1.0) - category: str | None = Field( - default=None, - pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$", - ) + category: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$") class G7ReliabilityClaim(BaseModel): @@ -103,11 +100,7 @@ class G7ReliabilityClaim(BaseModel): method: Literal["ICC(A,1)"] = "ICC(A,1)" labeler_keys: tuple[str, ...] = Field(min_length=2) reported_icc: float = Field(ge=-1.0, le=1.0) - reported_categorical_kappa: float | None = Field( - default=None, - ge=-1.0, - le=1.0, - ) + reported_categorical_kappa: float = Field(ge=-1.0, le=1.0) report_sha256: Sha256 = Field(pattern=r"^[a-f0-9]{64}$") @model_validator(mode="after") @@ -146,9 +139,6 @@ class G7PairedAxisObservation(BaseModel): labeler_keys = [item.labeler_key for item in self.labels] if len(set(labeler_keys)) != len(labeler_keys): raise ValueError("observation labeler keys must be unique") - has_category = [item.category is not None for item in self.labels] - if any(has_category) and not all(has_category): - raise ValueError("categorical labels must be complete within an observation") return self @@ -227,7 +217,6 @@ class G7HumanVoiceGainEvidencePack(BaseModel): participant_by_session: dict[str, str] = {} axes_by_session: dict[str, set[AllianceAxis]] = {} - categorical_modes: set[bool] = set() for observation in self.observations: if split_by_participant.get(observation.participant_key) != "held_out": raise ValueError("evaluation observations must use held-out participants") @@ -243,16 +232,8 @@ class G7HumanVoiceGainEvidencePack(BaseModel): row_labelers = {item.labeler_key for item in observation.labels} if row_labelers != reliability_panel: raise ValueError("every row must use the declared reliability panel") - categorical_modes.add(observation.labels[0].category is not None) required_axes: set[AllianceAxis] = {"goal", "task", "bond"} if any(axes != required_axes for axes in axes_by_session.values()): raise ValueError("every held-out session must cover goal, task, and bond") - if len(categorical_modes) != 1: - raise ValueError("categorical labels must be all-present or all-absent") - has_categories = True in categorical_modes - if has_categories != ( - self.reliability.reported_categorical_kappa is not None - ): - raise ValueError("categorical labels and reported kappa must appear together") return self diff --git a/apps/api/app/services/g7_voice_gain_evidence.py b/apps/api/app/services/g7_voice_gain_evidence.py index 1a4480e..49bffcb 100644 --- a/apps/api/app/services/g7_voice_gain_evidence.py +++ b/apps/api/app/services/g7_voice_gain_evidence.py @@ -51,7 +51,9 @@ class VoiceGainEvidenceResult(BaseModel): passed: bool clinical_claim_allowed: Literal[False] = False held_out_participants: int + total_held_out_sessions: int held_out_sessions: int + total_axis_observations: int paired_axis_observations: int intention_to_evaluate_imputations: int text_only_one_minus_mae: float @@ -62,7 +64,7 @@ class VoiceGainEvidenceResult(BaseModel): confidence_level: float bootstrap_samples: int recomputed_icc: float - recomputed_categorical_kappa: float | None + recomputed_categorical_kappa: float checks: tuple[VoiceGainEvidenceCheck, ...] failure_reasons: tuple[str, ...] @@ -221,22 +223,23 @@ def evaluate_human_voice_gain( labels_by_key = {item.labeler_key: item for item in observation.labels} ordered_labels = [labels_by_key[labeler_key] for labeler_key in panel] ratings.append([item.score for item in ordered_labels]) - if ordered_labels[0].category is not None: - categories.append([str(item.category) for item in ordered_labels]) + categories.append([item.category for item in ordered_labels]) reference = sum(item.score for item in ordered_labels) / len(ordered_labels) - if observation.text_only_status == "observed": + text_observed = observation.text_only_status == "observed" + voice_observed = observation.voice_enabled_status == "observed" + if text_observed and voice_observed: assert observation.text_only_score is not None text_error = abs(observation.text_only_score - reference) - else: - text_error = 1.0 - imputation_count += 1 - if observation.voice_enabled_status == "observed": assert observation.voice_enabled_score is not None voice_error = abs(observation.voice_enabled_score - reference) else: + # 한 조건만 결측이어도 두 조건을 모두 최대 오류로 대치한다. baseline-only + # 결측이 candidate gain을 인위적으로 키우는 비대칭을 차단하면서도 + # intention-to-evaluate 행은 분석에서 유지한다. + text_error = 1.0 voice_error = 1.0 - imputation_count += 1 + imputation_count += int(not text_observed) + int(not voice_observed) errors_by_participant[observation.participant_key].append( (text_error, voice_error) ) @@ -258,11 +261,28 @@ def evaluate_human_voice_gain( seed=thresholds.seed, ) recomputed_icc = _icc_absolute_agreement_single(ratings) - recomputed_kappa = _fleiss_kappa(categories) if categories else None + recomputed_kappa = _fleiss_kappa(categories) held_out_participants = len(errors_by_participant) - held_out_sessions = len({item.session_key for item in pack.observations}) - observation_count = len(pack.observations) + all_session_keys = {item.session_key for item in pack.observations} + complete_axes_by_session: dict[str, set[str]] = defaultdict(set) + for observation in pack.observations: + if ( + observation.text_only_status == "observed" + and observation.voice_enabled_status == "observed" + ): + complete_axes_by_session[observation.session_key].add(observation.axis) + required_axes = {"goal", "task", "bond"} + held_out_sessions = sum( + axes == required_axes for axes in complete_axes_by_session.values() + ) + total_observation_count = len(pack.observations) + paired_observation_count = sum( + 1 + for observation in pack.observations + if observation.text_only_status == "observed" + and observation.voice_enabled_status == "observed" + ) checks: list[VoiceGainEvidenceCheck] = [] _check( checks, @@ -281,8 +301,8 @@ def evaluate_human_voice_gain( _check( checks, "production_observation_floor", - observation_count >= thresholds.min_paired_axis_observations, - observation_count, + paired_observation_count >= thresholds.min_paired_axis_observations, + paired_observation_count, thresholds.min_paired_axis_observations, ) _check( @@ -318,8 +338,8 @@ def evaluate_human_voice_gain( _check( checks, "power_plan_observations_achieved", - observation_count >= pack.power_plan.required_paired_axis_observations, - observation_count, + paired_observation_count >= pack.power_plan.required_paired_axis_observations, + paired_observation_count, pack.power_plan.required_paired_axis_observations, ) _check( @@ -355,26 +375,24 @@ def evaluate_human_voice_gain( recomputed_icc, pack.reliability.reported_icc, ) - if recomputed_kappa is not None: - assert pack.reliability.reported_categorical_kappa is not None - _check( - checks, - "recomputed_categorical_kappa", - recomputed_kappa >= thresholds.min_categorical_kappa, - recomputed_kappa, - thresholds.min_categorical_kappa, - ) - _check( - checks, - "reported_kappa_matches_rows", - math.isclose( - recomputed_kappa, - pack.reliability.reported_categorical_kappa, - abs_tol=0.0005, - ), + _check( + checks, + "recomputed_categorical_kappa", + recomputed_kappa >= thresholds.min_categorical_kappa, + recomputed_kappa, + thresholds.min_categorical_kappa, + ) + _check( + checks, + "reported_kappa_matches_rows", + math.isclose( recomputed_kappa, pack.reliability.reported_categorical_kappa, - ) + abs_tol=0.0005, + ), + recomputed_kappa, + pack.reliability.reported_categorical_kappa, + ) _check(checks, "minimum_paired_gain", gain >= thresholds.min_gain, gain, thresholds.min_gain) _check(checks, "bootstrap_ci_excludes_zero", ci_lower > 0.0, ci_lower, "> 0") _check( @@ -390,8 +408,10 @@ def evaluate_human_voice_gain( passed=not failures, clinical_claim_allowed=False, held_out_participants=held_out_participants, + total_held_out_sessions=len(all_session_keys), held_out_sessions=held_out_sessions, - paired_axis_observations=observation_count, + total_axis_observations=total_observation_count, + paired_axis_observations=paired_observation_count, intention_to_evaluate_imputations=imputation_count, text_only_one_minus_mae=text_accuracy, voice_enabled_one_minus_mae=voice_accuracy, diff --git a/apps/api/app/test_g7_voice_gain_evidence.py b/apps/api/app/test_g7_voice_gain_evidence.py index 55f7ac0..482a791 100644 --- a/apps/api/app/test_g7_voice_gain_evidence.py +++ b/apps/api/app/test_g7_voice_gain_evidence.py @@ -179,8 +179,34 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase): self.assertEqual(result.intention_to_evaluate_imputations, 1) expected_voice_accuracy = 1.0 - ((1.0 + (0.05 * 5)) / 6) self.assertAlmostEqual(result.voice_enabled_one_minus_mae, expected_voice_accuracy) + self.assertEqual(result.paired_axis_observations, 5) + self.assertEqual(result.held_out_sessions, 1) + self.assertIn("production_observation_floor", result.failure_reasons) + self.assertIn("production_session_floor", result.failure_reasons) self.assertFalse(result.passed) + def test_baseline_only_missing_cannot_inflate_candidate_gain(self) -> None: + complete = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload()) + complete_result = evaluate_human_voice_gain( + complete, + thresholds=_test_thresholds(), + ) + payload = _valid_payload() + observations = payload["observations"] + assert isinstance(observations, list) + observations[0]["text_only_status"] = "missing" + observations[0]["text_only_score"] = None + missing = G7HumanVoiceGainEvidencePack.model_validate(payload) + + missing_result = evaluate_human_voice_gain( + missing, + thresholds=_test_thresholds(), + ) + + self.assertLess(missing_result.paired_gain, complete_result.paired_gain) + self.assertEqual(missing_result.intention_to_evaluate_imputations, 1) + self.assertEqual(missing_result.paired_axis_observations, 5) + def test_duplicate_or_calibration_observation_is_rejected(self) -> None: duplicate = _valid_payload() duplicate_rows = duplicate["observations"] @@ -236,6 +262,64 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase): self.assertFalse(result.passed) self.assertIn("reported_icc_matches_rows", result.failure_reasons) + def test_categorical_kappa_is_mandatory_and_recomputed(self) -> None: + missing_report = _valid_payload() + reliability = missing_report["reliability"] + assert isinstance(reliability, dict) + reliability.pop("reported_categorical_kappa") + with self.assertRaises(ValidationError): + G7HumanVoiceGainEvidencePack.model_validate(missing_report) + + missing_category = _valid_payload() + observations = missing_category["observations"] + assert isinstance(observations, list) + labels = observations[0]["labels"] + assert isinstance(labels, list) + labels[0].pop("category") + with self.assertRaises(ValidationError): + G7HumanVoiceGainEvidencePack.model_validate(missing_category) + + mismatched = _valid_payload() + reliability = mismatched["reliability"] + assert isinstance(reliability, dict) + reliability["reported_categorical_kappa"] = 0.8 + pack = G7HumanVoiceGainEvidencePack.model_validate(mismatched) + result = evaluate_human_voice_gain(pack, thresholds=_test_thresholds()) + self.assertIn("reported_kappa_matches_rows", result.failure_reasons) + + weak = _valid_payload() + observations = weak["observations"] + assert isinstance(observations, list) + for observation in observations: + labels = observation["labels"] + assert isinstance(labels, list) + labels[0]["category"] = "low" + labels[1]["category"] = "high" + reliability = weak["reliability"] + assert isinstance(reliability, dict) + reliability["reported_categorical_kappa"] = -1.0 + weak_pack = G7HumanVoiceGainEvidencePack.model_validate(weak) + weak_result = evaluate_human_voice_gain( + weak_pack, + thresholds=_test_thresholds(), + ) + self.assertIn("recomputed_categorical_kappa", weak_result.failure_reasons) + + def test_analysis_registration_must_strictly_precede_held_out_access(self) -> None: + payload = _valid_payload() + provenance = payload["provenance"] + assert isinstance(provenance, dict) + provenance["registered_at"] = provenance["held_out_labels_opened_at"] + with self.assertRaisesRegex(ValidationError, "must precede"): + G7HumanVoiceGainEvidencePack.model_validate(payload) + + wrong_alpha = _valid_payload() + power_plan = wrong_alpha["power_plan"] + assert isinstance(power_plan, dict) + power_plan["alpha"] = 0.01 + with self.assertRaises(ValidationError): + G7HumanVoiceGainEvidencePack.model_validate(wrong_alpha) + def test_custom_thresholds_require_explicit_test_factory(self) -> None: pack = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload()) with self.assertRaisesRegex(ValueError, "test-only"): diff --git a/apps/web/e2e/dev-dashboard.spec.ts b/apps/web/e2e/dev-dashboard.spec.ts index a39dc4b..ac933bf 100644 --- a/apps/web/e2e/dev-dashboard.spec.ts +++ b/apps/web/e2e/dev-dashboard.spec.ts @@ -333,6 +333,7 @@ test.describe("dev dashboard static command center", () => { for (const viewport of [ { width: 1440, height: 900 }, { width: 390, height: 844 }, + { width: 320, height: 568 }, ]) { await page.setViewportSize(viewport); await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`); diff --git a/apps/web/e2e/periodic-learner-real-closed-loop.spec.ts b/apps/web/e2e/periodic-learner-real-closed-loop.spec.ts new file mode 100644 index 0000000..4a2b93f --- /dev/null +++ b/apps/web/e2e/periodic-learner-real-closed-loop.spec.ts @@ -0,0 +1,426 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + expect, + test, + type APIResponse, + type Page, +} from "@playwright/test"; +import { + completeAlliancePreCheckpoint, + useRealApi, +} from "./support"; + +interface ReturnedPracticeFixture { + schema_version: "vignette.returned-practice-browser-fixture.v1"; + login: { + email: string; + role: "learner"; + display_name: string; + cohort_ids: string[]; + }; + source_session_id: string; + practice_session_id: string; + deliberate: { + prescription_id: string; + criterion_id: string; + novelty: "familiar" | "unseen_transfer"; + mode: + | "replay" + | "branch" + | "constrained_response" + | "voice_retry" + | "difficulty_ladder"; + }; + transfer: { + prescription_id: string; + suite_id: string; + trial_id: string; + criterion_id: string; + novelty: "unseen_transfer"; + mode: + | "counterevidence_forecast" + | "evidence_recall" + | "uncertainty_range" + | "collect_more_evidence"; + }; +} + +interface SessionDetail { + session_id: string; + persona_code: string; +} + +interface AuthMe { + user_id: string; +} + +interface DeliberateSubmission { + idempotent_replay: boolean; +} + +interface DeliberateReadModel { + episodes: Array<{ + session_id?: string | null; + attempts?: unknown[]; + }>; +} + +interface TransferSubmission { + idempotent_replay: boolean; + assessment: { + execution_count: number; + independent_execution_count: number; + }; +} + +interface TransferReadModel { + actual_executions: Array<{ + original_transfer_trial_record_id: string; + practice_session_id: string; + }>; +} + +const LIVE_GATE = process.env.E2E_PERIODIC_LEARNER_REAL_CLOSED_LOOP === "1"; +const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? ""; +const RESULT_PATH = process.env.E2E_PERIODIC_LEARNER_RESULT ?? ""; + +function loadFixture(): ReturnedPracticeFixture { + if (!FIXTURE_PATH) { + throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required"); + } + return JSON.parse( + readFileSync(path.resolve(FIXTURE_PATH), "utf8"), + ) as ReturnedPracticeFixture; +} + +async function expectOk(response: APIResponse) { + expect(response.ok(), await response.text()).toBeTruthy(); +} + +async function signInExistingFixture( + page: Page, + fixture: ReturnedPracticeFixture, +): Promise { + const login = await page.request.post("/api/auth/dev-login", { + data: fixture.login, + }); + await expectOk(login); + const me = await page.request.get("/api/auth/me"); + await expectOk(me); + return ((await me.json()) as AuthMe).user_id; +} + +function launchSearch( + fixture: ReturnedPracticeFixture, + kind: "deliberate" | "transfer", +): string { + const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer; + const search = new URLSearchParams({ + launch: kind, + prescription: source.prescription_id, + source_session: fixture.source_session_id, + criterion: source.criterion_id, + novelty: source.novelty, + mode: source.mode, + }); + if (kind === "transfer") { + search.set("suite", fixture.transfer.suite_id); + search.set("trial", fixture.transfer.trial_id); + } + return search.toString(); +} + +function runtimeAttemptCount( + readModel: DeliberateReadModel, + practiceSessionId: string, +): number { + return readModel.episodes + .filter((episode) => episode.session_id === practiceSessionId) + .reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0); +} + +function actualExecutionCount( + readModel: TransferReadModel, + fixture: ReturnedPracticeFixture, + practiceSessionId: string, +): number { + return readModel.actual_executions.filter( + (execution) => + execution.original_transfer_trial_record_id === fixture.transfer.trial_id && + execution.practice_session_id === practiceSessionId, + ).length; +} + +async function readPracticeCount(page: Page, practiceSessionId: string) { + const response = await page.request.get("/api/practice/learners/me"); + await expectOk(response); + return runtimeAttemptCount( + (await response.json()) as DeliberateReadModel, + practiceSessionId, + ); +} + +async function readTransferCount( + page: Page, + fixture: ReturnedPracticeFixture, + practiceSessionId: string, +) { + const response = await page.request.get("/api/calibration/learners/me"); + await expectOk(response); + return actualExecutionCount( + (await response.json()) as TransferReadModel, + fixture, + practiceSessionId, + ); +} + +async function openEvidenceCard( + page: Page, + fixture: ReturnedPracticeFixture, + practiceSessionId: string, + kind: "deliberate" | "transfer", +) { + await page.goto( + `/learn/session/${practiceSessionId}/review?${launchSearch(fixture, kind)}`, + ); + const insightsTab = page.locator("#sr-tab-insights"); + await expect(insightsTab).toBeVisible({ timeout: 30_000 }); + if ((await insightsTab.getAttribute("aria-selected")) !== "true") { + await insightsTab.click(); + } + const selector = + kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer"; + const card = page.locator("#sr-panel-insights").locator(selector); + await expect(card).toBeVisible({ timeout: 30_000 }); + return card; +} + +function writeResult(result: Record) { + if (!RESULT_PATH) { + throw new Error("E2E_PERIODIC_LEARNER_RESULT is required"); + } + const resolved = path.resolve(RESULT_PATH); + mkdirSync(path.dirname(resolved), { recursive: true }); + writeFileSync(resolved, `${JSON.stringify(result, null, 2)}\n`, "utf8"); +} + +test.describe("periodic same-learner real closed loop", () => { + test.skip(!LIVE_GATE, "Explicit disposable periodic gate only"); + + test("@single-run home recommendation, SSE session, review, G4/G5 POST and reload stay one learner", async ({ + page, + }) => { + test.setTimeout(20 * 60_000); + const fixture = loadFixture(); + expect(fixture.schema_version).toBe( + "vignette.returned-practice-browser-fixture.v1", + ); + await useRealApi(page); + const learnerId = await signInExistingFixture(page, fixture); + + const preparedSession = await page.request.get( + `/api/sessions/${fixture.practice_session_id}`, + ); + await expectOk(preparedSession); + const practicePersona = ((await preparedSession.json()) as SessionDetail) + .persona_code; + expect(practicePersona).toBeTruthy(); + + await page.goto("/learn"); + await expect( + page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("다음 연습 추천", { exact: true })).toBeVisible(); + await expect(page.getByText("리뷰 확인을 우선합니다.", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "리뷰 확인하기" }).click(); + await expect(page).toHaveURL(/\/learn\/history$/); + + await page.goto(`/learn/practice?${launchSearch(fixture, "deliberate")}`); + await expect( + page.getByRole("heading", { name: /처방을 이어받았습니다/ }), + ).toBeVisible({ timeout: 30_000 }); + const escapedPersona = practicePersona.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const persona = page.getByRole("option", { + name: new RegExp(escapedPersona), + }); + await persona.click(); + await expect(persona).toHaveAttribute("aria-selected", "true"); + await page.getByRole("button", { name: "새 회기 시작" }).click(); + await expect(page).toHaveURL(new RegExp(`/learn/session/${escapedPersona}\\?`)); + await page.getByRole("button", { name: "회기 시작" }).click(); + await completeAlliancePreCheckpoint(page); + + const activeUrl = new URL(page.url()); + const practiceSessionId = activeUrl.pathname.split("/").filter(Boolean).at(-1); + expect(practiceSessionId).toBeTruthy(); + expect(practiceSessionId).not.toBe(practicePersona); + + const learnerText = + "그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. 제가 이해한 마음이 맞을까요?"; + const streamResponse = page.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().endsWith(`/api/sessions/${practiceSessionId}/stream`), + { timeout: 6 * 60_000 }, + ); + await page.getByLabel("학습자 발화 입력").fill(learnerText); + await page.getByRole("button", { name: "보내기" }).click(); + const stream = await streamResponse; + await expectOk(stream); + expect(stream.headers()["content-type"] ?? "").toContain("text/event-stream"); + await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible(); + await expect(page.locator(".sx-utt.is-client").last()).toBeVisible({ + timeout: 6 * 60_000, + }); + + await page.getByRole("button", { name: "회기 종료" }).click(); + await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); + await expect(page).toHaveURL( + new RegExp(`/learn/session/${practiceSessionId}/review\\?`), + ); + await expect + .poll( + async () => { + const review = await page.request.get( + `/api/sessions/${practiceSessionId}/review`, + ); + if (!review.ok()) return false; + return Boolean(((await review.json()) as { reviewReady?: boolean }).reviewReady); + }, + { timeout: 6 * 60_000, intervals: [1_000, 2_000, 5_000] }, + ) + .toBe(true); + + expect(await readPracticeCount(page, practiceSessionId!)).toBe(0); + let deliberateCard = await openEvidenceCard( + page, + fixture, + practiceSessionId!, + "deliberate", + ); + const g4PostUrl = `/api/practice/${encodeURIComponent( + fixture.deliberate.prescription_id, + )}/attempts/from-session/${practiceSessionId}`; + const g4FirstPost = page.waitForResponse( + (response) => + response.request().method() === "POST" && response.url().endsWith(g4PostUrl), + { timeout: 5 * 60_000 }, + ); + await deliberateCard + .getByRole("button", { name: "이번 회기를 독립 관찰로 반영" }) + .click(); + const g4First = await g4FirstPost; + await expectOk(g4First); + expect(((await g4First.json()) as DeliberateSubmission).idempotent_replay).toBe( + false, + ); + const g4FirstCount = await readPracticeCount(page, practiceSessionId!); + expect(g4FirstCount).toBe(1); + await page.reload(); + deliberateCard = await openEvidenceCard( + page, + fixture, + practiceSessionId!, + "deliberate", + ); + const g4ReplayPost = page.waitForResponse( + (response) => + response.request().method() === "POST" && response.url().endsWith(g4PostUrl), + { timeout: 5 * 60_000 }, + ); + await deliberateCard + .getByRole("button", { name: "반영 상태 다시 확인" }) + .click(); + const g4Replay = await g4ReplayPost; + await expectOk(g4Replay); + expect(((await g4Replay.json()) as DeliberateSubmission).idempotent_replay).toBe( + true, + ); + const g4ReplayCount = await readPracticeCount(page, practiceSessionId!); + expect(g4ReplayCount).toBe(1); + + expect(await readTransferCount(page, fixture, practiceSessionId!)).toBe(0); + let transferCard = await openEvidenceCard( + page, + fixture, + practiceSessionId!, + "transfer", + ); + const g5PostUrl = "/api/calibration/transfer-executions"; + const g5FirstPost = page.waitForResponse( + (response) => + response.request().method() === "POST" && response.url().endsWith(g5PostUrl), + { timeout: 5 * 60_000 }, + ); + await transferCard + .getByRole("button", { name: "이 회기를 전이 근거로 확인" }) + .click(); + const g5First = await g5FirstPost; + await expectOk(g5First); + const g5FirstBody = (await g5First.json()) as TransferSubmission; + expect(g5FirstBody.idempotent_replay).toBe(false); + expect(g5FirstBody.assessment.execution_count).toBe(1); + expect(g5FirstBody.assessment.independent_execution_count).toBe(1); + const g5FirstCount = await readTransferCount( + page, + fixture, + practiceSessionId!, + ); + expect(g5FirstCount).toBe(1); + await page.reload(); + transferCard = await openEvidenceCard( + page, + fixture, + practiceSessionId!, + "transfer", + ); + const g5ReplayPost = page.waitForResponse( + (response) => + response.request().method() === "POST" && response.url().endsWith(g5PostUrl), + { timeout: 5 * 60_000 }, + ); + await transferCard + .getByRole("button", { name: "같은 회기 기록 다시 확인" }) + .click(); + const g5Replay = await g5ReplayPost; + await expectOk(g5Replay); + expect(((await g5Replay.json()) as TransferSubmission).idempotent_replay).toBe( + true, + ); + const g5ReplayCount = await readTransferCount( + page, + fixture, + practiceSessionId!, + ); + expect(g5ReplayCount).toBe(1); + + const meAfter = await page.request.get("/api/auth/me"); + await expectOk(meAfter); + expect(((await meAfter.json()) as AuthMe).user_id).toBe(learnerId); + + writeResult({ + schema_version: "vignette.periodic-learner-real-closed-loop.v1", + learner_id: learnerId, + source_session_id: fixture.source_session_id, + practice_session_id: practiceSessionId, + same_learner: true, + recommendation_verified: true, + session_created_in_browser: true, + sse_turn_verified: true, + review_ready: true, + g4: { + initial_count: 0, + first_count: g4FirstCount, + replay_count: g4ReplayCount, + replay_idempotent: true, + }, + g5: { + initial_count: 0, + first_count: g5FirstCount, + replay_count: g5ReplayCount, + replay_idempotent: true, + }, + }); + }); +}); diff --git a/apps/web/e2e/self-directed-learning-loop.spec.ts b/apps/web/e2e/self-directed-learning-loop.spec.ts index f5adccd..56b34df 100644 --- a/apps/web/e2e/self-directed-learning-loop.spec.ts +++ b/apps/web/e2e/self-directed-learning-loop.spec.ts @@ -1020,6 +1020,20 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi ); await expectReadableButtonContrast(page, "학습 대상 선택"); await expectNoHorizontalOverflow(page); + if (testInfo.project.name.includes("mobile")) { + await page.setViewportSize({ width: 320, height: 568 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expectInsideInitialViewport( + page, + "[data-learner-primary-action]", + "320px 학습자 홈 핵심 행동", + ); + await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭"); + await expectNoHorizontalOverflow(page); + await capture(page, testInfo.project.name, "home-320x568"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + } await capture(page, testInfo.project.name, "home"); await primaryAction.click(); @@ -1061,6 +1075,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi await page.getByRole("button", { name: "보내기" }).click(); await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible(); await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible(); + await expect(page.locator(".sx-page")).not.toContainText("[NAME]"); if (testInfo.project.name.includes("mobile")) { await expect( page.getByRole("region", { name: "현재 회기 요약" }), @@ -1145,7 +1160,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증"); await expect(practiceCard).not.toContainText("criterion.reflect-and-check"); await expect(practiceCard).not.toContainText("unseen_transfer_not_verified"); + await expect(page.locator(".sr-root")).not.toContainText("[NAME]"); await expectNoHorizontalOverflow(page); + if (testInfo.project.name.includes("mobile")) { + await page.setViewportSize({ width: 320, height: 568 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await practiceCard.scrollIntoViewIfNeeded(); + await expectMinimumHitTargets( + page, + ".dp-launch-ticket__cta", + "320px 처방 연습 시작 행동", + ); + await expectNoHorizontalOverflow(page); + await capture(page, testInfo.project.name, "review-prescription-320x568"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + } await practiceCard.scrollIntoViewIfNeeded(); await capture(page, testInfo.project.name, "review-prescription"); @@ -1185,6 +1215,16 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi await expect(page.locator(".lh-root")).toContainText("원본 회기 참조"); await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check"); await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8)); + await expect(page.locator(".lh-root")).not.toContainText("[NAME]"); + if (testInfo.project.name.includes("mobile")) { + await page.setViewportSize({ width: 320, height: 568 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동"); + await expectNoHorizontalOverflow(page); + await capture(page, testInfo.project.name, "repractice-home-320x568"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + } await page.getByRole("button", { name: "새 회기 시작" }).click(); await expect( page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }), @@ -1209,6 +1249,24 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi "처방 재연습 시작 행동", ); await expectNoHorizontalOverflow(page); + if (testInfo.project.name.includes("mobile")) { + await page.setViewportSize({ width: 320, height: 568 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expectInsideInitialViewport( + page, + ".sx-prestart__actions .vg-btn", + "320px 처방 재연습 시작 행동", + ); + await expectMinimumHitTargets( + page, + ".sx-prestart__actions .vg-btn", + "320px 처방 재연습 시작 행동", + ); + await expectNoHorizontalOverflow(page); + await capture(page, testInfo.project.name, "repractice-prestart-320x568"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + } await capture(page, testInfo.project.name, "repractice-prestart"); await page.getByRole("button", { name: "회기 시작" }).click(); @@ -1297,6 +1355,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan( readsBeforeSuccess, ); + if (testInfo.project.name.includes("mobile")) { + await page.setViewportSize({ width: 320, height: 568 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await observation.scrollIntoViewIfNeeded(); + await expectMinimumHitTargets( + page, + ".dp-runtime-observation button", + "320px 서버 관찰 행동", + ); + await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8)); + await expect(observation).not.toContainText("[NAME]"); + await expectNoHorizontalOverflow(page); + await capture(page, testInfo.project.name, "repractice-observation-320x568"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + } await observation.scrollIntoViewIfNeeded(); await capture(page, testInfo.project.name, "repractice-observation"); diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts index fc0d624..e4f9e70 100644 --- a/apps/web/e2e/session-layout.spec.ts +++ b/apps/web/e2e/session-layout.spec.ts @@ -206,13 +206,13 @@ async function expectMainControlsUnclipped(page: Page) { const controls = [ { selector: ".sx-compose textarea", parent: ".sx-compose" }, { selector: ".sx-compose .vg-btn", parent: ".sx-compose" }, - { selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" }, + { selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar", minTouchSize: 44 }, { selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" }, - { selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" }, - { selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar" }, + { selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar", minTouchSize: 44 }, + { selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar", minTouchSize: 44 }, ]; - return controls.map(({ selector, parent }) => { + return controls.map(({ selector, parent, minTouchSize }) => { const el = document.querySelector(selector); const parentEl = document.querySelector(parent); if (!el || !parentEl) return { selector, ok: false, reason: "missing" }; @@ -236,11 +236,21 @@ async function expectMainControlsUnclipped(page: Page) { rect.left >= parentRect.left - 1 && rect.right <= parentRect.right + 1 && rect.bottom <= parentRect.bottom + 1; + const touchTargetOk = + minTouchSize === undefined || (rect.width >= minTouchSize && rect.height >= minTouchSize); return { selector, - ok: visible && insideParent && !textClipped, - reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "", + ok: visible && insideParent && !textClipped && touchTargetOk, + reason: !visible + ? "not-visible" + : !insideParent + ? "outside-parent" + : textClipped + ? "text-clipped" + : !touchTargetOk + ? "touch-target-under-44px" + : "", rect: { top: Math.round(rect.top), right: Math.round(rect.right), diff --git a/apps/web/src/pages/session/session.css b/apps/web/src/pages/session/session.css index deffd7f..e2ec40c 100644 --- a/apps/web/src/pages/session/session.css +++ b/apps/web/src/pages/session/session.css @@ -2386,7 +2386,7 @@ flex: none; } .sx-end-button { - min-height: 40px; + min-height: 44px; padding: 9px 13px; border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle)); border-radius: var(--radius); @@ -4210,6 +4210,20 @@ } } +/* 처방 재연습은 카드 안에 과제 종류·성공 기준·출처를 다시 명시한다. 320×568처럼 + 세로가 아주 짧은 화면에서 상단 단계 헤더까지 반복하면 유일한 주 행동인 "회기 시작"이 + 첫 화면 아래로 밀린다. 이 구간만 중복 헤더를 접고 셸 여백을 줄여 과제 맥락과 CTA를 + 함께 보이게 한다. 일반 회기 준비 화면과 390×844 이상 레이아웃은 그대로 유지한다. */ +@media (max-width: 460px) and (max-height: 620px) { + .vg-main:has(.sx-page--prestart .sx-prestart--prescribed) { + padding-top: var(--sp-2); + padding-bottom: var(--sp-3); + } + .sx-page--prestart:has(.sx-prestart--prescribed) .sx-head { + display: none; + } +} + @media (max-width: 1180px) and (max-height: 820px) { .sx-sessionbar { min-height: 38px; @@ -4355,19 +4369,18 @@ .sx-page--active .sx-mic-block__ms { display: none; } - /* 아주 좁거나 낮은 뷰포트: 세로 공간 확보를 위해 40px 로 낮춘다 - (390x720 등 여유 있는 폭에서는 44px 유지). 가로 터치 폭은 유지. */ + /* 아주 좁거나 낮은 뷰포트에서도 주요 회기 제어는 44px 터치 하한을 지킨다. */ .sx-page--active .sx-mic { - width: 40px; - height: 40px; + width: 44px; + height: 44px; } .sx-page--active .sx-pause { width: 44px; - height: 40px; + height: 44px; } .sx-page--active .sx-end-button { min-width: 104px; - height: 40px; + height: 44px; padding: 0 10px; } } diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 704ce66..a541d89 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -453,6 +453,10 @@ G7 내부 소스 계약은 완료됐고 `scripts/check-g7-external-proof.py`는 `apps/api/requirements.txt`와 명시적 Python 3.11 모두 `psutil==6.1.1`로 고정됐다. - 현재 운영 STT 모델은 이 호스트에서 실측된 CPU int8 `small`로 고정한다. cuDNN 9가 설치되고 별도 성능·정확도 gate를 통과하기 전까지 launcher·API runtime metadata·50분 runner/checker expected model을 모두 `small`로 유지한다. +- human voice-gain pack은 category와 categorical κ를 필수로 포함하고, preregistration이 held-out 공개보다 앞서야 하며, + 양 조건이 모두 관측된 50회기/150축만 paired 표본으로 집계한다. 한쪽 결측이면 양쪽 모두 최대오류 ITT로 처리한다. + `scripts/check-g7-human-voice-gain.py --input `을 먼저 통과하지 못하면 production runner는 마이크를 + 열기 전에 exit 2로 끝난다. 스키마는 `--print-schema`로 출력한다. 관련 API+script 계약은 97/97 통과했다. - 무마이크 rehearsal 산출물은 `D:\workspace\vignette-runtime-evidence\g7-rehearsal-b34623f3db05-20260809T134105Z`에 있다. voice `61af2e98…009c`, runtime `c6e8670e…4454`, topology `89f1cb86…a251`이며 세 leg 모두 passed, @@ -673,9 +677,14 @@ node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line ``` -전체 Playwright inventory는 626 tests / 44 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 +전체 Playwright inventory는 627 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다. +주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고 +route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부르면 안 된다. 새 runner는 clean HEAD/tree에서만 +전용 engine/DB/API/Web을 만들고 같은 학습자의 SSE→review→G4/G5 0→1→1과 returned-practice desktop/mobile 4건을 +검증한 뒤 sentinel resource·PID·listener·temp 잔여 0을 영수증으로 남긴다. 공개 8001/55432/9099와 NAS는 금지다. + ## 9. 핵심 변경 파일 - `scripts/launch-nas-preview-g8-helpers.py` · `scripts/test_launch_nas_preview_g8_helpers.py` (신규, Gate6 계약) diff --git a/docs/TODO.md b/docs/TODO.md index d2fe043..5dc3c1a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -110,6 +110,9 @@ (2) **독립 blind human voice-gain pack** — held-out 30명 외 calibration split 참가자 포함 총 최소 31명 / held-out 50회기 / 150 paired axis / 독립 평가자 2인 / ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0. + runner는 이 pack의 category·κ·preregistration·paired completeness·결측 ITT를 3,120초 캡처 전에 검증하며, + 부적합하면 마이크를 열지 않고 exit 2로 끝낸다. 독립 검수는 + `python -X utf8 -B scripts/check-g7-human-voice-gain.py --input `이고 `--print-schema`로 계약을 확인한다. **공개 선행 조건 완료(2026-08-09):** detached-clean `b34623f3…`·tree `32cc85c7…`에서 OpenAPI 126, `/admin/voice-runtime`, exact local voice provider/model과 queue 4를 제공한다. fresh launcher receipt `9f8d1941…a21bf`가 passed이고 두 Scheduled Task도 동일 source에 pin돼 result 0이다. Cloudflare의 Python @@ -120,7 +123,7 @@ `≥3000s`를 checker가 강제한다. Windows artifact는 detached-clean HEAD/tree, runner/collector/checker SHA, exact `psutil==6.1.1`을 매 sample 전후 pin한다. fresh launcher는 legacy API와 exact-config cloudflared를 bounded 교체하고 새 PID/start/exe/command SHA/실제 cwd의 raw command-line 없는 receipt를 만든다. G7 runner/checker/topology - 87/87과 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000, + human pack 계약 97/97과 기존 runtime sampler 4/4를 통과했다. `--rehearse`는 인증 WSS ready/ping/close 1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 마이크 capture false·UUID/email literal 0이다. fail-closed 경계(동의 없음·pack 없음·3,120초 미만·host/Origin/scheme 불일치)는 CLI로 실증했다. 상세: `ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`. @@ -136,7 +139,9 @@ > dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다. > 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다. > 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다. -> 매일 04:30 KST `vignette-e2e` 자동화는 ACTIVE다. +> 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고, +> 소스 material milestone에서만 전용 disposable 회기 E2E를 실행한다. runner의 clean HEAD/tree·로컬 npipe Docker· +> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 10/10은 완료됐고, 첫 실제 GREEN receipt는 clean commit 뒤 남았다. > 증거: [배포 증거](./ops/nas-preview-deployment-evidence-2026-08-07.md), [기계 판독 증거](./ops/evidence/nas-preview-current-deploy-2026-08-07.json), [브라우저 증거](./ops/evidence/nas-preview-live-turn-2026-08-07.png). 이는 G7 물리 마이크·독립 사람 평가 증거를 대체하지 않는다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 3f862a1..c56de74 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -272,7 +272,14 @@ .mstrip{flex-wrap:nowrap;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin;} .mchip{flex:0 0 auto;} } - @media (max-width:460px){ .kpis{grid-template-columns:1fr;} h1{font-size:22px;} .stamp{display:none;} } + @media (max-width:460px){ + .kpis{grid-template-columns:1fr;} + h1{font-size:22px;} + .stamp{display:none;} + .dg-note{overflow-wrap:anywhere;} + .ocols{grid-template-columns:minmax(0,1fr);} + .ocol,.ostack,.ocard{min-width:0;max-width:100%;width:auto;} + } @media (prefers-reduced-motion:reduce){ *{transition:none!important;} } @@ -658,6 +665,7 @@

190차 적용(2026-08-09): exact include 52개를 clean commit 5221f79e…1c69으로 고정하고 tree e4f15001…308b, 2회 동일 archive 1109bf86…0f4b를 결속했다. fresh NAS dump 6f4b95a7…b529f(1,015,222 bytes·TOC 1,752/TABLE DATA 129) 뒤 execute는 API 921·types·typecheck·build·insecure-context 6/6·candidate DB 회기 후반까지 통과했지만 110/112에서 승격 전에 fail-closed했다. 실패 2건은 Vite entry /src/main.tsx만 차단하던 boot 진단 테스트가 production hashed entry /assets/index-*.js를 차단하지 못한 환경 계약 누락이었다. 두 entry를 함께 차단하도록 고쳐 Vite source 2/2와 production preview 2/2를 통과했다. NAS mutation·rollback·DB migration은 0이며, 테스트 수정의 새 clean commit을 재결속한 한 번의 execute가 남았다.

191차 적용(2026-08-09): 후속 clean commit 21461ab3…6fde·tree 5c5f12a8…524e·archive 20d49694…fa1a는 candidate 112/112를 통과하고 NAS 새 API/Web d5021950…/376a3aa8…를 올렸다. 실제 NAS-origin postdeploy G4 desktop/mobile 2건이 110/112에서 실패하자 active-state commit 전에 이전 52e0e816…/6fdbb646…로 자동 rollback했고 health·auth 401·OpenAPI 126·G0~G8 route·image ID를 재검증해 rollback verified로 종료했다. exact-image 평문 origin에서 문장 fingerprint의 crypto.subtle.digest 의존이 요청 전 예외를 내는 원인을 확인했다. 원문 외부 전송 없이 portable SHA-256 fallback을 추가하고 표준 digest를 payload에 exact 고정해 secure localhost 2/2와 Tailnet insecure origin 2/2를 통과했다. 새 clean commit의 전체 candidate+NAS-origin 0 failure 전까지 G8은 runtime REVALIDATION이다.

192차 적용(2026-08-09): G8 clean-head 최종 승격을 완료했다. source HEAD 61a41d1f…6af·tree 87dec55d…3b77에서 2회 동일하게 만든 archive 4d15d055d74f…119d4d를 active release로 고정했고 candidate session E2E 112/112와 실제 http://100.116.83.60:8088 평문 NAS-origin browser review 112/112(11 specs, desktop/mobile/single-run)를 모두 통과했다. 실행 이미지는 API d5021950…e4b1·Web 9796c092…4b36이며 health는 status=ok·db=true·engine=true, 비인증 auth 401, OpenAPI 126을 재확인했다. execute 직전 fresh custom dump 36ec8748…24db8(1,097,100 bytes, TOC 1,738 / TABLE DATA 129)를 보존했고 이전 active 6030a677…c611과 이전 이미지는 rollback 기준으로 남겼다. 앞선 fail-closed·verified rollback 이력은 삭제하지 않는다. 이 증거로 G8을 DONE으로 승격하며 남은 Outcome OS gate는 G7 external proof다.

+

193차 적용(2026-08-09): 학생 화면의 320×568 신뢰성 게이트를 닫았다. 처방 재연습 prestart CTA를 첫 화면에 유지하고, active 회기의 마이크·일시정지·종료를 모든 밀도 구간에서 최소 44px로 고정했으며, 대시보드의 긴 privacy token과 owner grid가 320px 문서를 384px까지 넓히던 overflow를 제거했다. typecheck, session-layout desktop/mobile 8/8, dev-dashboard desktop/mobile 10/10, 자기주도 focused 16/16을 통과했다. 동시에 NAS 112건을 실 API/DB 22와 route fixture 90으로 정직하게 분리하고, clean HEAD/tree·전용 disposable stack·동일 학습자 SSE→review→G4/G5 0→1→1·exact cleanup을 강제하는 주기 runner를 추가했다. unit 10/10과 Compose preflight는 통과했으며 첫 실제 GREEN receipt는 이 변경의 clean commit 뒤 실행한다. G7 human pack은 category·κ≥0.70·preregistration·paired completeness·결측 ITT를 캡처 전에 검증해 부적합 pack이면 마이크를 열지 않는다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -912,7 +920,7 @@
-
내부 구현·공개 선행조건 DONE

consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 local_whisper/melotts이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·psutil version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean b34623f3…·tree 32cc85c7…로 fresh 승격돼 OpenAPI 126·/admin/voice-runtime·local_whisper/small·melotts/melotts-korean·WS queue 4를 제공한다. receipt 9f8d1941…a21bf가 passed이고 두 source-pinned task도 result 0이다. current six-suite 87/87·runtime sampler 4/4를 통과했다.

외부 종료 GATE

30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 physical_capture=false·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. 네 artifact가 scripts/check-g7-external-proof.py exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.

+
내부 구현·공개 선행조건 DONE

consent→단일 clock→독립 text/voice/fusion→철회·tombstone, streaming interim/final·word timestamp·provider event, HMAC word pseudonym, 열린 stream의 1초 동의 재검사·abort, 텍스트 보존·음성 재연결 UX를 연결했다. 운영 기본 provider는 노트북 상주 local_whisper/melotts이고 외부 Deepgram/OpenAI adapter는 fallback으로 보존한다. 격리 synthetic PCM soak는 59/59를 통과했다. Windows host topology는 detached-clean commit/tree·도구 SHA·psutil version과 PID/start/exe·command SHA/cwd·process/TCP high-water를 fail-closed로 수집하고, 3,120초 capture의 공통 3,000초를 canonical exit와 결속한다. 공개 API/cloudflared는 clean b34623f3…·tree 32cc85c7…로 fresh 승격돼 OpenAPI 126·/admin/voice-runtime·local_whisper/small·melotts/melotts-korean·WS queue 4를 제공한다. receipt 9f8d1941…a21bf가 passed이고 두 source-pinned task도 result 0이다. human pack은 category·κ·preregistration·paired completeness·결측 ITT를 캡처 전에 검증하며 관련 계약 97/97·runtime sampler 4/4를 통과했다.

외부 종료 GATE

30초 무마이크 rehearsal은 authenticated public WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 physical_capture=false·UUID/email literal 0이다. 남은 것은 사용자가 선택한 장치와 실행 직전 명시 동의를 받은 물리 마이크 3,120초 양방향 soak, 같은 public host·공통 3,000초 시간창의 worker/Uvicorn queue와 process/TCP high-water, 최소 31명·held-out 50회기·150축·blind evaluator 2인의 독립 human voice-gain pack이다. pack은 check-g7-human-voice-gain.py를 먼저 통과해야 하며 부적합하면 마이크를 열기 전에 exit 2다. 네 artifact가 scripts/check-g7-external-proof.py exit 0을 만들기 전에는 메인 상태를 DONE으로 바꾸지 않는다.

@@ -1068,7 +1076,7 @@ Web typechecknpm run typecheckPassed Design SSOT / auth visualnpm run check:design-ssot / npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line / npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=lineSSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed. - Full Playwright E2E baselinenpm run e2e:parallel / npm run e2e:single-run / npm run e2e:list2026-08-09 현재 수집은 626 tests / 44 files다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다. + Full Playwright E2E baselinenpm run e2e:parallel / npm run e2e:single-run / npm run e2e:list2026-08-09 현재 수집은 627 tests / 45 files다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다. Refactor governance P1~P8ruff check app / pytest -q app / pytest -q engine_gateway / npm run typecheck / npm run check:api-types / npm run check:design-ssot / npm run check:dead-code / npm run check:duplication / npm run build / npm audit --audit-level=high / full PlaywrightBackend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 ops/refactor-governance-2026-07-15.md. API typegen SSOTnpm run check:api-typesPassed; FastAPI OpenAPI → src/lib/api.gen.ts stale check Outcome & Alliance OS G0py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q / scripts/check-measurement-ledger.sql / measurement·API contract checks / web typecheck / DB-backed session-persistence focused E2E 3종G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료. @@ -1145,7 +1153,7 @@ Docker image smokedocker build -f apps/api/Dockerfile . / docker run ... python -c "import app.main" / docker build -f apps/web/Dockerfile apps/webAPI/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host node_modules; API image excludes local .env files. Deploy preflightpython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / DB mode with local DATABASE_URLPassed: exact-pinned API requirements, live coaching data/kb source pack, env template keys, and DB readiness (current_user=vignette). DB mode can additionally check app-role DSN with --require-app-role and now verifies session read-model columns including app.turns voice metadata columns(audio_ref/silence_ms/speech_rate/barge_in/provider_events) plus worksheet review columns. Fresh compose smokedocker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build + proxy /api/healthPassed with dummy production-safe env: API healthy, DB healthy, web/proxy up, http://localhost:18080/api/health 200 with db:true, engine:true, engine_mode:"claude_cli". Smoke volumes/network removed after run. - 격리 NAS 프리뷰 · 회기 E2E 자동화http://100.116.83.60:8088 / vignette-e2e 매일 04:30 KST / 배포 증거 / 기계 판독 증거current clean source HEAD 61a41d1f…6af·tree 87dec55d…3b77의 archive 4d15d055…119d4d를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate session E2E 112/112와 실제 NAS 평문 origin browser review 112/112, health ok·db/engine true, OpenAPI 126·auth 401을 통과했고 fresh dump와 이전 active/images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate·NAS preflight+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다. + 격리 NAS 프리뷰 · 회기 E2E 자동화http://100.116.83.60:8088 / vignette-e2e 매시간 heartbeat / 배포 증거 / 기계 판독 증거current clean source HEAD 61a41d1f…6af·tree 87dec55d…3b77의 archive 4d15d055…119d4d를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate 112/112와 실제 NAS-origin 112/112는 통과했지만 영수증은 실 API/DB 22건과 route fixture 90건을 분리한다. 자동화는 공개/NAS 화면과 health를 읽기 전용으로 매시간 보여준다. material milestone의 동일 학습자 SSE→review→G4/G5 actual 폐루프는 clean HEAD/tree·전용 engine/DB/API/Web·보호 포트 거부·sentinel·exact cleanup runner가 소유하며 unit 10/10은 통과했다. 첫 실제 GREEN receipt는 clean commit 뒤 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다. G7 외부 증거 3-artifact 동시 시간창 오케스트레이터scripts/run-g7-external-proof-window.py · 준비 문서soak·runtime·topology 세 캡처를 같은 host의 공통 3,000초 시간창으로 실행하고 checker까지 잇는다. 공개 배포·프로세스·source pin은 clean commit b34623f3…와 fresh receipt 9f8d1941…로 완료했다. Cloudflare가 Python 기본 User-Agent를 403으로 거부하는 경계도 브라우저 호환 header와 회귀 4/4로 고정했다. 30초 rehearsal은 세 leg 7 samples를 모두 통과했으며 마이크를 열지 않았다. 남은 선행 조건: 사용자의 물리 장치 선택과 명시 동의, 독립 human voice-gain pack이다. G7 external GATE · 최종 감사 정정run-g7-external-proof-window.pycheck-g7-external-proof.py상태는 external GATE 유지. runner 프로세스 exit는 canonical checker exit 0·gate_closed=true에 결속되고, browser Origin과 API transport host는 별도 allowlist로 검증된다. 세 artifact 교집합은 ≥3000s + 120초 margin이고 Windows proof는 detached-clean HEAD/tree·tool SHA·psutil==6.1.1을 pin한다. clean-source 배포와 무마이크 rehearsal은 완료했다. 남은 것은 물리 마이크 3,120초, 동시 runtime/topology, 독립 human pack을 합친 4-artifact 실증뿐이다. G8 DONE · clean-head NAS 기준선vignette-preview-20260807 · run-outcome-os-release-agent.py상태는 DONE. active archive 4d15d055…119d4d, source HEAD/tree 61a41d1f…6af/87dec55d…3b77, API/Web d5021950…e4b1/9796c092…4b36 exact running. candidate 112/112와 actual NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. fresh dump 36ec8748…24db8 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active 6030a677…c611을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다. diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 469188d..eb06761 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타 | API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | -| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 626 tests / 44 files · 현 작업트리 전체 GREEN 미검증 | +| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 627 tests / 45 files · 현 작업트리 전체 GREEN 미검증 | 핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.** **E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다. @@ -271,10 +271,17 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API ### 3.6 실측 테스트 개수 (현재) -2026-08-09 `npx playwright test --list` 기준 **현재 수집 626 tests / 44 files**다. -이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 626개 완주는 아직 증거가 없으며, +2026-08-09 `npx playwright test --list` 기준 **현재 수집 627 tests / 45 files**다. +이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 627개 완주는 아직 증거가 없으며, 과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다. +NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 `session-layout`·`session-persistence` +22건을 `real_api_db_specs`, 나머지 route fixture 90건을 `route_fixture_specs`로 분리한다. +학생 동일 계정의 홈 추천→브라우저 회기 생성→실제 SSE→review ready→G4/G5 POST·reload→0→1→1은 +`scripts/run-periodic-learner-e2e.py --execute`가 전용 disposable engine/DB/API/Web에서만 실행한다. +이 runner는 clean HEAD/tree, 로컬 npipe Docker, loopback 동적 포트, sentinel과 exact cleanup을 강제하며 +공개 8001·DB 55432·engine 9099와 NAS를 구조적으로 거부한다. 첫 GREEN receipt 전에는 스케줄 등록하지 않는다. + - **병렬 시나리오**: 166 tests (desktop 83 + mobile 83) - **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등) - 2026-08-06 Outcome & Alliance OS G0 검증: `test_measurement_contract.py` + runtime schema SSOT **11 passed**, @@ -432,6 +439,10 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 모두 통과했고 `physical_capture=false`, UUID/email literal 0이다. current G7 six-suite는 87/87, runtime sampler는 4/4다. 이 리허설은 실제 마이크 3,120초·공통 3,000초 high-water·독립 human pack·canonical checker exit 0을 대체하지 않는다. +- 2026-08-09 G7 human pack preflight: production runner는 캡처 전에 category·categorical κ≥0.70, + preregistration 선행, 50회기/150축 complete pairing, 양 조건 결측 ITT를 검증한다. 독립 검수는 + `python -X utf8 -B scripts/check-g7-human-voice-gain.py --input `이며 `--print-schema`도 지원한다. + 부적합 pack은 마이크를 열기 전에 exit 2다. API evaluator 11/11 + G7 script 86/86, 합계 97/97 통과했다. - 2026-08-07 G7/G8 시각 QA: 데스크톱·Pixel 5에서 focused **16/16**, typecheck·build·cosmetic-filter를 통과했다. G8 승인 차단 이유를 `title` 의존에서 상시 문구·`aria-describedby`·semantic form/Enter 제출로 바꾸고, G7 이벤트 최소 24×24px와 시간축 키보드 포커스를 수치 회귀로 고정했다. 상세는 diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 3fe440e..60077f7 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -90,8 +90,8 @@ PID/start/exe/command SHA/cwd safe receipt를 만든다. current six-suite 87/87과 runtime sampler 4/4를 통과했다. 무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다. 격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와 - review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매일 04:30 KST `Vignette 회기 E2E 정기 검증` - (automation id `vignette-e2e`)은 ACTIVE이며 material milestone+release gate·NAS preflight+배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 첫 예약 실행 이력은 대기 상태다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), + review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증` + heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여주고, material milestone에서만 전용 disposable 회기 E2E를 실행한다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 10/10은 완료됐고 첫 실제 GREEN receipt는 clean commit 뒤 남았다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), [브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 명시 동의 물리 마이크와 독립 human voice-gain 증거는 아직 없으므로 G7은 external GATE로 유지한다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·Anthropic `/v1/models` 탐색·지원 추론 강도·관리자 fail-closed 저장 경로는 구현 완료. 남은 범위는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 live 응답·계량·오류 표면화 비교다. Claude CLI·Codex CLI(Terra/Medium)·Agy CLI(Gemini 3.6 Flash/High)는 로컬 live probe를 통과했다. - [ ] **stable-source 재부팅 후 watchdog smoke** — watchdog·로그온 boot는 detached-clean `b34623f3…` stable diff --git a/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md b/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md index 5e4ea44..3c93b1f 100644 --- a/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md +++ b/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md @@ -28,9 +28,11 @@ G7은 **내부 구현 DONE · external GATE**다. 이번 변경은 물리 마이 SHA, `psutil==6.1.1`, 새 API/cloudflared PID/start/exe·command SHA/실제 cwd를 매 sample 전후 검증한다. checker는 voice/runtime/topology 세 artifact의 **공통 시간창이 3,000초 이상**인지 재계산한다. - 독립 human-labeled held-out voice-gain 계약은 raw audio·transcript·synthetic pack을 거부하고, - 사전등록·동의·participant split·model artifact·blind independent labeler provenance를 요구한다. - production gate는 30명/50회기/150 paired axis, ICC(A,1) 0.75, 선택적 κ 0.70, gain 0.01, - participant-cluster bootstrap 10,000회의 95% CI lower `> 0`을 재계산한다. + held-out 공개 전에 끝난 사전등록·동의·participant split·model artifact·blind independent labeler provenance를 요구한다. + production gate는 최소 31명(held-out 30명 외 calibration split 포함), 양 조건이 모두 관측된 완전 paired + 50회기/150축, ICC(A,1) 0.75, **필수 categorical κ 0.70**, gain 0.01, + participant-cluster bootstrap 10,000회의 95% CI lower `> 0`을 재계산한다. 한 조건이라도 결측이면 양 조건을 + 모두 최대오류로 처리해 baseline-only 결측이 candidate gain을 부풀리지 못하게 한다. - `scripts/check-g7-external-proof.py`는 같은 public host와 겹치는 시간창의 public soak, process-local runtime sampling, pinned topology sampling, independent human-gain pack 네 가지를 모두 통과시켜야 성공한다. G7 release gate는 이 checker 증거가 있을 때만 @@ -39,8 +41,8 @@ G7은 **내부 구현 DONE · external GATE**다. 이번 변경은 물리 마이 ## 검증 - API 전체 `921 passed`, gateway `58 passed`, API voice 통합 `71 passed`. -- runner/checker/topology `86 passed`, public launcher/sidecar `80 passed`, G7 통합 `166 passed`; - Ruff·`py_compile`·PowerShell 5.1 parser·`git diff --check` 통과. +- current G7 scripts `86/86`, API human evaluator `11/11`; + Ruff·`py_compile`·`git diff --check` 통과. - Web typecheck와 API type contract 통과. - `voice-success.spec.ts` chromium single-run `2/2` 통과. 동의 원장 응답을 의도적으로 지연한 동안 `getUserMedia=0`, voice WebSocket `0`을 확인했고, 성공 뒤 각각 정확히 1회였다. @@ -70,8 +72,17 @@ DONE으로 표시하지 않는다. 앞의 1~3번은 **같은 public transport host의 공통 3,000초 시간창**이어야 하는데, 지금까지는 운영자가 세 명령을 따로 띄우고 시계를 손으로 맞춰야 했다. 50분짜리 실행에서 한 번 어긋나면 처음부터 다시 해야 한다. -`scripts/run-g7-external-proof-window.py`가 세 캡처를 **동시에** 시작하고, 전부 끝나면 human pack을 -더해 checker까지 그대로 돌린다. +`scripts/run-g7-external-proof-window.py`는 production human pack을 **마이크를 열기 전에 먼저 완전 검증**하고, +통과한 경우에만 세 캡처를 동시에 시작한다. 종료 뒤 canonical checker가 같은 pack과 세 runtime artifact를 다시 검증한다. +runtime/topology interval 기본값은 child collector 상한과 같은 60초이며, 60초 초과는 시작 전에 차단한다. + +사람 데이터 운영자는 52분 실행과 독립적으로 pack을 먼저 검증할 수 있다. `--print-schema`는 authoritative JSON Schema를 +출력하고, 오류는 JSON pointer와 유형만 반환해 participant/labeler key·라벨 값·PII를 반사하지 않는다. + +```powershell +& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --print-schema +& $py -X utf8 -B scripts/check-g7-human-voice-gain.py --input +``` ```powershell # 실제 실행 (물리 마이크 50분 + 사람 pack 필요) @@ -98,7 +109,9 @@ DONE으로 표시하지 않는다. |---|---| | 동의·장치 없이 운영 실행 | `physical_microphone_consent_required`, exit 2 | | human pack 없이 운영 실행 | `human_voice_gain_pack_required`, exit 2 | +| malformed·underpowered·κ 미달·불완전 paired human pack | 물리 캡처 전에 human-pack 오류, exit 2 | | 3,120초 미만 시간창 | `production_window_too_short`, exit 2 | +| runtime/topology interval 60초 초과 | child collector 실행 전 interval 오류, exit 2 | | transport host/scheme 불일치 | `hosts_must_match` 또는 scheme 오류, exit 2 | | 허용되지 않은 browser Origin | query/원문을 반사하지 않는 Origin 오류, exit 2 | @@ -106,33 +119,28 @@ DONE으로 표시하지 않는다. 열지 않고, 인자·경로·산출 파일까지 실제로 검증한다. 보고서의 `gate_closed`는 rehearse에서 **항상 false**다. 실제 50분 실행 전에 이걸로 먼저 실패를 뽑아내라. -### ⚠ 선행 조건 — 공개 런타임을 먼저 올려야 한다 (2026-08-08 발견) +### 공개 선행 조건 — 2026-08-09 완료 -**마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 artifact 2에서 실패한다.** +공개 API/cloudflared는 detached-clean `b34623f3…`·tree `32cc85c7…`에서 fresh 승격됐다. 공개 OpenAPI 126, +`/admin/voice-runtime`, `local_whisper/small`, `melotts/melotts-korean`, Uvicorn WS queue 4와 receipt +`9f8d1941…a21bf` passed를 확인했다. watchdog·로그온 task도 같은 stable root에 source pin돼 명시 실행 결과 0이다. -공개 런타임은 **119 paths 구버전**이고 `/admin/voice-runtime`이 **배포돼 있지 않다**. -`capture-g7-runtime-evidence.py`는 정확히 그 경로만 부르므로 artifact 2를 만들 수 없다. -현재 소스에는 있다(`apps/api/app/routes/admin.py:1699`, NAS 프리뷰 기준 126 paths). - -``` -공개 OpenAPI 119 paths · voice 경로 = /users/me/voice-presets, /voice/health, /voice/speech -has /admin/voice-runtime → False -``` - -그래서 오케스트레이터는 시작 전에 이걸 검사하고 `admin_voice_runtime_not_deployed`로 즉시 멈춘다. -실측: 공개 런타임 대상 `--rehearse` 실행이 exit 2로 몇 초 만에 차단됐다. +30초 `--rehearse`는 authenticated WSS ready/ping/close1000, runtime 7 samples, Windows topology 7 samples를 +모두 통과했다. evidence는 `physical_capture=false`이며 UUID/email literal 0이다. 이 리허설은 실제 물리 마이크와 +human pack을 대체하지 않는다. **판정을 미인증 HTTP 상태로 하지 않는다.** Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는 경로까지 포함해 **모든 경로를 403(error code 1010)** 으로 돌려주기 때문에, 상태 코드로는 "배포 누락"과 "edge 차단"을 구분할 수 없다. 처음엔 404/401 판정으로 짰다가 이 사실을 확인하고 **OpenAPI spec의 `paths`만 authoritative하게 쓰도록** 고쳤다. 회귀에 그 이유를 테스트로 남겼다. -따라서 G7 실행 순서는 이렇다. +따라서 남은 G7 실행 순서는 이렇다. -1. current source를 공개 런타임에 승격한다(비-secure origin `crypto.randomUUID` 수정도 여기 포함된다). -2. `--rehearse`로 배관을 확인한다. -3. 동의 하 물리 마이크 50분 + human pack으로 실제 실행한다. +1. 독립 연구팀이 실제 human pack을 작성하고 standalone validator를 통과시킨다. +2. 사용자가 정확한 마이크 장치를 고르고 3,120초 물리 캡처에 명시 동의한다. +3. production runner로 human pack을 재검증한 뒤 mic/runtime/topology 동시 창을 실행한다. +4. canonical checker exit 0과 `gate_closed=true`를 확인한다. 기대 provider 기본값은 2026-08-08 결정에 맞춰 `local_whisper/small` / `melotts/melotts-korean`이다 -(근거: `../decisions/local-voice-stack.md`). runner 회귀는 37/37, G7 통합은 166/166이다. production exit 0은 +(근거: `../decisions/local-voice-stack.md`). current G7 scripts는 86/86, API human evaluator는 11/11이다. production exit 0은 `checker_returncode == 0 && gate_closed is true`에 결속되며 rehearse는 성공해도 gate를 닫지 않는다. diff --git a/scripts/check-g7-human-voice-gain.py b/scripts/check-g7-human-voice-gain.py new file mode 100644 index 0000000..60a5234 --- /dev/null +++ b/scripts/check-g7-human-voice-gain.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Validate and evaluate one independent human-held-out G7 voice-gain pack. + +The command emits only aggregate metrics and PII-safe JSON pointers. It never +echoes input paths, participant/labeler keys, labels, or raw validation values. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Iterable + +from pydantic import ValidationError + + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_ROOT = REPO_ROOT / "apps/api" +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + +from app.contracts.g7_external_evidence import ( # noqa: E402 + G7HumanVoiceGainEvidencePack, +) +from app.services.g7_voice_gain_evidence import ( # noqa: E402 + evaluate_human_voice_gain, +) + + +def _json_pointer(location: tuple[int | str, ...]) -> str: + if not location: + return "/" + parts = [] + for item in location: + value = str(item).replace("~", "~0").replace("/", "~1") + parts.append(value) + return "/" + "/".join(parts) + + +def _base_report() -> dict[str, Any]: + return { + "schema_version": "vignette.g7-human-voice-gain-check.v1", + "passed": False, + "clinical_claim_allowed": False, + "privacy_boundary": { + "input_path_logged": False, + "participant_keys_logged": False, + "labeler_keys_logged": False, + "labels_logged": False, + "raw_validation_values_logged": False, + }, + "validation_errors": [], + "result": {}, + } + + +def validate_payload(payload: object) -> dict[str, Any]: + report = _base_report() + try: + pack = G7HumanVoiceGainEvidencePack.model_validate(payload) + except ValidationError as exc: + report["validation_errors"] = [ + { + "pointer": _json_pointer(tuple(item["loc"])), + "type": item["type"], + } + for item in exc.errors( + include_url=False, + include_context=False, + include_input=False, + ) + ] + return report + + try: + result = evaluate_human_voice_gain(pack) + except Exception as exc: + report["validation_errors"] = [ + {"pointer": "/", "type": f"evaluation:{type(exc).__name__}"} + ] + return report + + report["passed"] = result.passed + report["result"] = result.model_dump(mode="json") + return report + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + mode = result.add_mutually_exclusive_group(required=True) + mode.add_argument("--input", type=Path, help="deidentified human pack JSON") + mode.add_argument( + "--print-schema", + action="store_true", + help="print the authoritative JSON Schema and exit", + ) + return result + + +def main(argv: Iterable[str] | None = None) -> int: + args = parser().parse_args(list(argv) if argv is not None else None) + if args.print_schema: + print( + json.dumps( + G7HumanVoiceGainEvidencePack.model_json_schema(), + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + ) + return 0 + + try: + payload = json.loads(args.input.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + report = _base_report() + report["validation_errors"] = [ + {"pointer": "/", "type": f"input:{type(exc).__name__}"} + ] + else: + report = validate_payload(payload) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report["passed"] is True else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-g7-external-proof-window.py b/scripts/run-g7-external-proof-window.py index 782c592..fa245d5 100644 --- a/scripts/run-g7-external-proof-window.py +++ b/scripts/run-g7-external-proof-window.py @@ -42,6 +42,16 @@ from typing import Any, Callable, Iterable, Sequence REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPTS = REPO_ROOT / "scripts" +API_ROOT = REPO_ROOT / "apps/api" +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + +from app.contracts.g7_external_evidence import ( # noqa: E402 + G7HumanVoiceGainEvidencePack, +) +from app.services.g7_voice_gain_evidence import ( # noqa: E402 + evaluate_human_voice_gain, +) SOAK_SCRIPT = SCRIPTS / "soak-public-voice-websocket.py" RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py" @@ -63,6 +73,8 @@ MIN_PRODUCTION_SECONDS = ( MIN_REQUIRED_OVERLAP_SECONDS + CAPTURE_START_SKEW_MARGIN_SECONDS ) RUNTIME_SAMPLE_MARGIN = 1 +MAX_CHILD_INTERVAL_SECONDS = 60.0 +DEFAULT_SAMPLE_INTERVAL_SECONDS = 60.0 class WindowError(RuntimeError): @@ -172,6 +184,23 @@ def sample_plan(duration_seconds: float, interval_seconds: float) -> int: return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN +def validate_human_voice_gain_pack(path: Path) -> None: + """52분 캡처를 열기 전에 사람 pack의 production gate를 완전히 계산한다.""" + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + pack = G7HumanVoiceGainEvidencePack.model_validate(payload) + result = evaluate_human_voice_gain(pack) + except (OSError, UnicodeError, json.JSONDecodeError): + raise WindowError("human_voice_gain_pack_unreadable") from None + except Exception: + # validation 원문에는 입력값이 포함될 수 있으므로 오류 code만 낸다. + raise WindowError("human_voice_gain_pack_invalid") from None + if not result.passed: + reasons = ",".join(result.failure_reasons) + raise WindowError(f"human_voice_gain_pack_failed:{reasons}") + + def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg: argv = [ sys.executable, @@ -533,8 +562,16 @@ def build_parser() -> argparse.ArgumentParser: default=MIN_PRODUCTION_SECONDS, help="실제 캡처는 최소 3120초; 유효 artifact 교집합 기준은 3000초", ) - parser.add_argument("--runtime-interval-seconds", type=float, default=100.0) - parser.add_argument("--topology-interval-seconds", type=float, default=100.0) + parser.add_argument( + "--runtime-interval-seconds", + type=float, + default=DEFAULT_SAMPLE_INTERVAL_SECONDS, + ) + parser.add_argument( + "--topology-interval-seconds", + type=float, + default=DEFAULT_SAMPLE_INTERVAL_SECONDS, + ) parser.add_argument("--human-voice-gain", type=Path) parser.add_argument("--out-dir", type=Path, required=True) parser.add_argument( @@ -550,6 +587,12 @@ def validate(args: argparse.Namespace) -> None: raise WindowError("production_window_too_short") if not args.rehearse and args.human_voice_gain is None: raise WindowError("human_voice_gain_pack_required") + for value, code in ( + (args.runtime_interval_seconds, "runtime_interval_out_of_bounds"), + (args.topology_interval_seconds, "topology_interval_out_of_bounds"), + ): + if not math.isfinite(value) or not 0.05 <= value <= MAX_CHILD_INTERVAL_SECONDS: + raise WindowError(code) if args.topology_mode not in ("linux-compose", "windows-host"): raise WindowError("topology_mode_invalid") if args.topology_mode == "linux-compose": @@ -589,6 +632,9 @@ def main(argv: Iterable[str] | None = None) -> int: args = build_parser().parse_args(list(argv) if argv is not None else None) try: validate(args) + if not args.rehearse: + assert args.human_voice_gain is not None + validate_human_voice_gain_pack(args.human_voice_gain) args.out_dir.mkdir(parents=True, exist_ok=True) legs = plan_legs(args, args.out_dir) except WindowError as exc: diff --git a/scripts/run-outcome-os-release-agent.py b/scripts/run-outcome-os-release-agent.py index dbfb837..4e0f8a0 100644 --- a/scripts/run-outcome-os-release-agent.py +++ b/scripts/run-outcome-os-release-agent.py @@ -88,6 +88,19 @@ POSTDEPLOY_NAS_E2E_SPECS = ( "e2e/multimodal-alliance.spec.ts", "e2e/continuous-improvement-admin.spec.ts", ) +# Only these specs use the configured NAS API/DB instead of replacing the +# product API with Playwright route fixtures. Keep the split explicit in the +# receipt so the 112-test browser total cannot be mistaken for 112 live +# session/database proofs. +POSTDEPLOY_NAS_REAL_API_E2E_SPECS = ( + "e2e/session-layout.spec.ts", + "e2e/session-persistence.spec.ts", +) +POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS = tuple( + spec + for spec in POSTDEPLOY_NAS_E2E_SPECS + if spec not in POSTDEPLOY_NAS_REAL_API_E2E_SPECS +) POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",) POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = ( "e2e/returned-practice-db-closed-loop.spec.ts", @@ -1572,8 +1585,13 @@ class ReleaseAgent: "base_url": self.config.target.base_url, "specs": list(POSTDEPLOY_NAS_E2E_SPECS), "projects": list(RELEASE_BROWSER_PROJECTS), + "runtime_scope": { + "real_api_db_specs": list(POSTDEPLOY_NAS_REAL_API_E2E_SPECS), + "route_fixture_specs": list(POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS), + }, "source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS), "separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS), + "separate_disposable_db_status": "not_run_by_release_agent", "uuid_runtime_route_specs": [ "e2e/session-persistence.spec.ts", "e2e/self-directed-learning-loop.spec.ts", diff --git a/scripts/run-periodic-learner-e2e.py b/scripts/run-periodic-learner-e2e.py new file mode 100644 index 0000000..27efd9c --- /dev/null +++ b/scripts/run-periodic-learner-e2e.py @@ -0,0 +1,1405 @@ +"""Run the learner closed loop only inside a disposable, HEAD-bound stack. + +The runner is fail closed. It never accepts caller-selected runtime ports or a +database URL, constructs every credential and endpoint itself, and tears down +only resources carrying its exact Compose project and sentinel labels. + +This script is intentionally not registered in Task Scheduler. Scheduling is +allowed only after one explicit ``--execute`` run produces a GREEN receipt. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Mapping, Protocol, Sequence +from urllib.parse import urlsplit + + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMPOSE_FILE = REPO_ROOT / "infra" / "docker-compose.yml" +FIXTURE_SCRIPT = ( + REPO_ROOT + / "apps" + / "web" + / "e2e" + / "harness" + / "prepare-returned-practice-db.py" +) +PERIODIC_SPEC = "e2e/periodic-learner-real-closed-loop.spec.ts" +RETURNED_SPEC = "e2e/returned-practice-db-closed-loop.spec.ts" +SCHEMA_VERSION = "vignette.periodic-learner-e2e-receipt.v1" +FORBIDDEN_PORTS = frozenset({8001, 55432, 9099}) +FORBIDDEN_MARKERS = ( + "vignette.chanpaca.net", + "api-vignette.chanpaca.net", + "100.116.83.60", + "vignette-preview-20260807", + "vignette-dev-db", + "/volume1/", + "\\\\100.116.83.60\\", +) +RUNTIME_ENV_KEYS = frozenset( + { + "DATABASE_URL", + "DATABASE_ADMIN_URL", + "DB_PORT", + "POSTGRES_PORT", + "ENGINE_URL", + "ENGINE_PORT", + "HTTP_PORT", + "HTTPS_PORT", + "PLAYWRIGHT_BASE_URL", + "E2E_PUBLIC_BASE_URL", + "PUBLIC_API_BASE", + "FRONTEND_BASE_URL", + "FRONTEND_ORIGIN_MAP", + "CORS_ORIGINS", + "AUTH_DEV_LOGIN_EXTRA_ORIGINS", + "COMPOSE_PROJECT_NAME", + "COMPOSE_FILE", + "COMPOSE_PROFILES", + "DOCKER_CONTEXT", + "DOCKER_HOST", + } +) +INHERITED_TARGET_ENV_KEYS = frozenset( + { + "DATABASE_URL", + "DATABASE_ADMIN_URL", + "DB_PORT", + "POSTGRES_PORT", + "PGHOST", + "PGPORT", + "ENGINE_URL", + "ENGINE_PORT", + "HTTP_PORT", + "HTTPS_PORT", + "PLAYWRIGHT_BASE_URL", + "E2E_PUBLIC_BASE_URL", + "FRONTEND_BASE_URL", + "COMPOSE_FILE", + "COMPOSE_PROJECT_NAME", + } +) +CRITICAL_UNTRACKED_SUFFIXES = frozenset( + {".py", ".ts", ".tsx", ".js", ".mjs", ".json", ".yml", ".yaml", ".sql"} +) +PROJECT_RE = re.compile(r"^vignette-periodic-[0-9a-f]{8}-[a-z0-9-]{6,32}$") +HEX_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +class GateError(RuntimeError): + """A bounded, receipt-safe gate failure.""" + + +@dataclass(frozen=True, slots=True) +class CommandResult: + returncode: int + stdout: str = "" + stderr: str = "" + duration_seconds: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class ProcessHandle: + pid: int + + +class Controller(Protocol): + def run( + self, + stage: str, + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str] | None = None, + timeout: float, + check: bool = True, + ) -> CommandResult: ... + + def start( + self, + stage: str, + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str], + stdout_path: Path, + stderr_path: Path, + ) -> ProcessHandle: ... + + def stop_exact(self, handle: ProcessHandle, *, timeout: float) -> bool: ... + + def http_json( + self, + url: str, + *, + headers: Mapping[str, str] | None = None, + timeout: float, + ) -> dict[str, Any]: ... + + def http_text(self, url: str, *, timeout: float) -> str: ... + + def tcp_listening(self, host: str, port: int, *, timeout: float = 0.25) -> bool: ... + + def sleep(self, seconds: float) -> None: ... + + +class LocalController: + def __init__(self) -> None: + self._processes: dict[int, tuple[subprocess.Popen[bytes], Any, Any]] = {} + + def run( + self, + stage: str, + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str] | None = None, + timeout: float, + check: bool = True, + ) -> CommandResult: + started = time.monotonic() + try: + completed = subprocess.run( + list(argv), + cwd=str(cwd), + env=dict(env) if env is not None else None, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise GateError(f"{stage}: command failed to run: {exc}") from exc + result = CommandResult( + completed.returncode, + completed.stdout, + completed.stderr, + time.monotonic() - started, + ) + if check and result.returncode != 0: + tail = (result.stderr or result.stdout).strip()[-1200:] + raise GateError(f"{stage}: exit {result.returncode}: {tail}") + return result + + def start( + self, + stage: str, + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str], + stdout_path: Path, + stderr_path: Path, + ) -> ProcessHandle: + stdout_handle = stdout_path.open("wb") + stderr_handle = stderr_path.open("wb") + try: + process = subprocess.Popen( + list(argv), + cwd=str(cwd), + env=dict(env), + stdin=subprocess.DEVNULL, + stdout=stdout_handle, + stderr=stderr_handle, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except OSError as exc: + stdout_handle.close() + stderr_handle.close() + raise GateError(f"{stage}: process failed to start: {exc}") from exc + self._processes[process.pid] = (process, stdout_handle, stderr_handle) + return ProcessHandle(process.pid) + + def stop_exact(self, handle: ProcessHandle, *, timeout: float) -> bool: + entry = self._processes.pop(handle.pid, None) + if entry is None: + return False + process, stdout_handle, stderr_handle = entry + try: + if process.poll() is None: + if os.name == "nt": + subprocess.run( + ["taskkill.exe", "/PID", str(handle.pid), "/T", "/F"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=timeout, + check=False, + ) + else: + process.terminate() + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=timeout) + return process.poll() is not None + finally: + stdout_handle.close() + stderr_handle.close() + + def http_json( + self, + url: str, + *, + headers: Mapping[str, str] | None = None, + timeout: float, + ) -> dict[str, Any]: + request = urllib.request.Request(url, headers=dict(headers or {})) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc: + raise GateError(f"HTTP JSON probe failed: {url}: {exc}") from exc + if not isinstance(payload, dict): + raise GateError(f"HTTP JSON probe did not return an object: {url}") + return payload + + def http_text(self, url: str, *, timeout: float) -> str: + request = urllib.request.Request(url) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8", errors="replace") + except (OSError, urllib.error.HTTPError) as exc: + raise GateError(f"HTTP text probe failed: {url}: {exc}") from exc + + def tcp_listening(self, host: str, port: int, *, timeout: float = 0.25) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(timeout) + return probe.connect_ex((host, port)) == 0 + + def sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +@dataclass(frozen=True, slots=True) +class SourceIdentity: + head: str + tree: str + + +@dataclass(frozen=True, slots=True) +class RuntimeIdentity: + run_id: str + project: str + sentinel: str + http_port: int + https_port: int + database_port: int + engine_port: int + + @property + def ports(self) -> tuple[int, ...]: + return ( + self.http_port, + self.https_port, + self.database_port, + self.engine_port, + ) + + +@dataclass(frozen=True, slots=True) +class RunnerConfig: + receipt_path: Path + python_exe: str + node_exe: str + docker_exe: str + execute: bool + request_timeout: float = 240.0 + readiness_timeout: float = 600.0 + + +@dataclass(slots=True) +class ExecutionState: + source: SourceIdentity | None = None + runtime: RuntimeIdentity | None = None + temp_dir: Path | None = None + env_file: Path | None = None + override_file: Path | None = None + engine: ProcessHandle | None = None + docker_context: str | None = None + stack_attempted: bool = False + stages: list[dict[str, Any]] = field(default_factory=list) + proof: dict[str, Any] = field(default_factory=dict) + + +def _sha256_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _safe_error(exc: BaseException) -> str: + return str(exc).replace(str(REPO_ROOT), "")[-1600:] + + +def assert_safe_value(label: str, value: str) -> None: + normalized = value.strip().lower().replace("\\", "/") + if any(marker.lower().replace("\\", "/") in normalized for marker in FORBIDDEN_MARKERS): + raise GateError(f"{label}: public/NAS marker is forbidden") + if normalized.isdigit() and int(normalized) in FORBIDDEN_PORTS: + raise GateError(f"{label}: protected runtime port is forbidden") + if re.search(r":(?:8001|55432|9099)(?:\D|$)", normalized): + raise GateError(f"{label}: protected runtime port is forbidden") + parsed = urlsplit(value) + try: + parsed_port = parsed.port + except ValueError as exc: + raise GateError(f"{label}: invalid endpoint") from exc + if parsed_port in FORBIDDEN_PORTS: + raise GateError(f"{label}: protected runtime port is forbidden") + + +def assert_safe_invocation(argv: Sequence[str], environ: Mapping[str, str]) -> None: + for index, value in enumerate(argv): + assert_safe_value(f"argv[{index}]", str(value)) + for key in RUNTIME_ENV_KEYS: + value = environ.get(key) + if value: + assert_safe_value(f"env:{key}", value) + + +def assert_no_inherited_runtime_targets(environ: Mapping[str, str]) -> None: + present = sorted(key for key in INHERITED_TARGET_ENV_KEYS if environ.get(key)) + if present: + raise GateError( + "inherited runtime target variables are forbidden: " + ", ".join(present) + ) + + +def assert_loopback_url(label: str, value: str, expected_port: int) -> None: + assert_safe_value(label, value) + parsed = urlsplit(value) + if parsed.scheme not in {"http", "postgresql"}: + raise GateError(f"{label}: unsupported scheme") + if parsed.hostname not in {"127.0.0.1", "localhost"}: + raise GateError(f"{label}: endpoint must be loopback") + if parsed.port != expected_port: + raise GateError(f"{label}: endpoint is not bound to the allocated port") + + +def allocate_unique_ports(count: int) -> tuple[int, ...]: + sockets: list[socket.socket] = [] + try: + while len(sockets) < count: + reservation = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + reservation.bind(("127.0.0.1", 0)) + port = int(reservation.getsockname()[1]) + if port in FORBIDDEN_PORTS or any( + int(item.getsockname()[1]) == port for item in sockets + ): + reservation.close() + continue + sockets.append(reservation) + return tuple(int(item.getsockname()[1]) for item in sockets) + finally: + for reservation in sockets: + reservation.close() + + +def build_runtime_identity(source: SourceIdentity) -> RuntimeIdentity: + run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%S')}-{secrets.token_hex(4)}" + project = f"vignette-periodic-{source.head[:8]}-{run_id[-8:]}" + if not PROJECT_RE.fullmatch(project): + raise GateError("generated Compose project is invalid") + http_port, https_port, database_port, engine_port = allocate_unique_ports(4) + sentinel = f"periodic:{source.head}:{source.tree}:{run_id}" + return RuntimeIdentity( + run_id, + project, + sentinel, + http_port, + https_port, + database_port, + engine_port, + ) + + +def validate_runtime_identity(runtime: RuntimeIdentity) -> None: + if not PROJECT_RE.fullmatch(runtime.project): + raise GateError("Compose project does not match the disposable namespace") + if len(set(runtime.ports)) != 4: + raise GateError("runtime ports must be unique") + if any(port in FORBIDDEN_PORTS or not 1024 < port < 65536 for port in runtime.ports): + raise GateError("runtime allocated a protected or invalid port") + for port in runtime.ports: + assert_safe_value("resolved-port", str(port)) + + +def _clean_child_env(overrides: Mapping[str, str]) -> dict[str, str]: + env = os.environ.copy() + for key in RUNTIME_ENV_KEYS: + env.pop(key, None) + env.update(overrides) + env["NO_PROXY"] = "127.0.0.1,localhost,host.docker.internal" + env["no_proxy"] = env["NO_PROXY"] + assert_safe_invocation([], env) + return env + + +def _secret(prefix: str) -> str: + return f"{prefix}-{secrets.token_hex(32)}" + + +def build_stack_environment(runtime: RuntimeIdentity) -> dict[str, str]: + base_url = f"http://127.0.0.1:{runtime.http_port}" + engine_url = f"http://host.docker.internal:{runtime.engine_port}" + values = { + "ENVIRONMENT": "dev", + "POSTGRES_USER": "vignette_owner", + "POSTGRES_PASSWORD": _secret("owner"), + "POSTGRES_DB": "vignette_periodic", + "APP_DB_USER": "vignette_app", + "APP_DB_PASSWORD": _secret("app"), + "ENGINE_URL": engine_url, + "ENGINE_MODE": "claude_cli", + "ENGINE_GATEWAY_SHARED_SECRET": _secret("engine"), + "OPENAI_API_KEY": _secret("sk-periodic"), + "SESSION_SECRET": _secret("session"), + "VIGNETTE_RUPTURE_INTERNAL_TOKEN": _secret("rupture"), + "VIGNETTE_PRACTICE_INTERNAL_TOKEN": _secret("practice"), + "VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN": _secret("transfer"), + "VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN": _secret("supervision"), + "VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN": _secret("alliance"), + "VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN": _secret("ci"), + "OAUTH_GOOGLE_CLIENT_ID": "periodic-local-client", + "OAUTH_GOOGLE_CLIENT_SECRET": _secret("oauth"), + "OAUTH_REDIRECT_URI": f"{base_url}/api/auth/callback", + "AUTH_ALLOWED_EMAIL_DOMAINS": json.dumps(["hs.ac.kr"], separators=(",", ":")), + "AUTH_TEACHER_EMAILS": "[]", + "AUTH_ADMIN_EMAILS": "[]", + "AUTH_DEV_LOGIN_ENABLED": "true", + "AUTH_DEV_LOGIN_EXTRA_ORIGINS": json.dumps([base_url], separators=(",", ":")), + "AUTO_SEED_PERSONAS": "true", + "ALLOW_SEED_PERSONA_FALLBACK": "false", + "FRONTEND_BASE_URL": base_url, + "FRONTEND_ORIGIN_MAP": json.dumps({"default": base_url}, separators=(",", ":")), + "CORS_ORIGINS": json.dumps([base_url], separators=(",", ":")), + "PUBLIC_API_BASE": "/api", + "HTTP_PORT": str(runtime.http_port), + "HTTPS_PORT": str(runtime.https_port), + "DB_HOST_PORT": str(runtime.database_port), + "PERIODIC_SENTINEL": runtime.sentinel, + "VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED": "false", + "VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED": "false", + "VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED": "false", + "NOTIFICATION_EMAIL_PROVIDER": "disabled", + } + assert_safe_invocation([], values) + return values + + +def write_env_file(path: Path, values: Mapping[str, str]) -> None: + lines = [f"{key}={value}" for key, value in sorted(values.items())] + path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + + +def write_compose_override(path: Path) -> None: + path.write_text( + """services: + db: + ports: + - \"127.0.0.1:${DB_HOST_PORT}:5432\" + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" + api: + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" + web: + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" + proxy: + ports: !override + - \"127.0.0.1:${HTTP_PORT}:80\" + - \"127.0.0.1:${HTTPS_PORT}:443\" + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" +volumes: + pgdata: + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" + apiuploads: + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" + caddydata: + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" +networks: + vignette: + labels: + com.vignette.periodic-e2e-sentinel: \"${PERIODIC_SENTINEL}\" +""", + encoding="utf-8", + newline="\n", + ) + + +class PeriodicRunner: + def __init__(self, config: RunnerConfig, controller: Controller) -> None: + self.config = config + self.controller = controller + self.state = ExecutionState() + + def _run( + self, + stage: str, + argv: Sequence[str], + *, + cwd: Path = REPO_ROOT, + env: Mapping[str, str] | None = None, + timeout: float = 120.0, + check: bool = True, + ) -> CommandResult: + assert_safe_invocation(argv, env or {}) + result = self.controller.run( + stage, + argv, + cwd=cwd, + env=env, + timeout=timeout, + check=check, + ) + self.state.stages.append( + { + "stage": stage, + "exit_code": result.returncode, + "duration_seconds": round(result.duration_seconds, 3), + "stdout_sha256": hashlib.sha256(result.stdout.encode("utf-8")).hexdigest(), + } + ) + return result + + def source_identity(self, *, require_clean: bool) -> SourceIdentity: + head = self._run( + "source_head", + ["git.exe", "rev-parse", "HEAD"], + timeout=20, + ).stdout.strip() + tree = self._run( + "source_tree", + ["git.exe", "rev-parse", "HEAD^{tree}"], + timeout=20, + ).stdout.strip() + if not HEX_SHA_RE.fullmatch(head) or not HEX_SHA_RE.fullmatch(tree): + raise GateError("git did not return a canonical HEAD/tree") + if require_clean: + tracked = self._run( + "source_tracked_clean", + ["git.exe", "status", "--porcelain", "--untracked-files=no"], + timeout=30, + ).stdout.strip() + if tracked: + raise GateError("tracked worktree differs from HEAD") + untracked = self._run( + "source_untracked_inventory", + ["git.exe", "status", "--porcelain", "--untracked-files=all"], + timeout=30, + ).stdout.splitlines() + dangerous: list[str] = [] + for row in untracked: + if not row.startswith("?? "): + continue + relative = row[3:].strip().replace("\\", "/") + candidate = Path(relative) + if ( + candidate.suffix.lower() in CRITICAL_UNTRACKED_SUFFIXES + and relative.startswith(("apps/api/", "apps/web/", "data/", "infra/")) + ): + dangerous.append(relative) + if dangerous: + raise GateError("untracked runtime source is present") + return SourceIdentity(head, tree) + + def _wait_json( + self, + label: str, + url: str, + predicate: Any, + *, + headers: Mapping[str, str] | None = None, + timeout: float, + ) -> dict[str, Any]: + assert_safe_value(label, url) + deadline = time.monotonic() + timeout + last_error = "not attempted" + while time.monotonic() < deadline: + try: + payload = self.controller.http_json(url, headers=headers, timeout=10) + if predicate(payload): + return payload + last_error = "predicate rejected response" + except GateError as exc: + last_error = _safe_error(exc) + self.controller.sleep(1.0) + raise GateError(f"{label}: readiness timeout: {last_error}") + + def _compose_argv(self, *tail: str) -> list[str]: + runtime = self._require_runtime() + if self.state.env_file is None or self.state.override_file is None: + raise GateError("Compose files are not initialized") + return [ + *self._docker_prefix(), + "compose", + "-p", + runtime.project, + "--env-file", + str(self.state.env_file), + "-f", + str(COMPOSE_FILE), + "-f", + str(self.state.override_file), + *tail, + ] + + def _docker_prefix(self) -> list[str]: + if not self.state.docker_context: + raise GateError("local Docker context is not pinned") + return [self.config.docker_exe, "--context", self.state.docker_context] + + def _pin_local_docker_context(self) -> dict[str, str]: + context = self._run( + "docker_context_show", + [self.config.docker_exe, "context", "show"], + timeout=30, + ).stdout.strip() + if not context or not re.fullmatch(r"[A-Za-z0-9_.-]+", context): + raise GateError("Docker context name is not canonical") + inspected = self._run( + "docker_context_inspect", + [self.config.docker_exe, "context", "inspect", context], + timeout=30, + ) + try: + payload = json.loads(inspected.stdout) + host = str(payload[0]["Endpoints"]["docker"]["Host"]) + except (json.JSONDecodeError, IndexError, KeyError, TypeError) as exc: + raise GateError("Docker context endpoint is unreadable") from exc + assert_safe_value("Docker context endpoint", host) + if not host.lower().startswith("npipe://"): + raise GateError("Docker context must use the local Windows named pipe") + self.state.docker_context = context + return {"context": context, "transport": "npipe", "remote": False} + + def _require_runtime(self) -> RuntimeIdentity: + if self.state.runtime is None: + raise GateError("runtime identity is not initialized") + return self.state.runtime + + def _resource_ids(self, kind: str) -> list[str]: + runtime = self._require_runtime() + noun = {"container": "ps", "volume": "volume", "network": "network"}[kind] + argv = [*self._docker_prefix(), noun] + if kind == "container": + argv.extend(["-aq"]) + else: + argv.extend(["ls", "-q"]) + argv.extend(["--filter", f"label=com.docker.compose.project={runtime.project}"]) + result = self._run(f"inventory_{kind}", argv, timeout=30) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + def _verify_resources_up(self) -> dict[str, int]: + runtime = self._require_runtime() + containers = self._resource_ids("container") + volumes = self._resource_ids("volume") + networks = self._resource_ids("network") + if len(containers) != 4 or len(volumes) != 3 or len(networks) != 1: + raise GateError("Compose did not create the exact disposable resource set") + labels = self._run( + "sentinel_container_labels", + [ + *self._docker_prefix(), + "inspect", + "--format", + '{{ index .Config.Labels "com.vignette.periodic-e2e-sentinel" }}', + *containers, + ], + timeout=30, + ).stdout.splitlines() + if len(labels) != 4 or any(value.strip() != runtime.sentinel for value in labels): + raise GateError("container sentinel label mismatch") + port_output = self._run( + "resolved_compose_ports", + self._compose_argv("port", "proxy", "80"), + timeout=30, + ).stdout.strip() + db_port_output = self._run( + "resolved_database_port", + self._compose_argv("port", "db", "5432"), + timeout=30, + ).stdout.strip() + for label, output, expected in ( + ("resolved proxy port", port_output, runtime.http_port), + ("resolved database port", db_port_output, runtime.database_port), + ): + assert_safe_value(label, output) + if not output.endswith(f":{expected}"): + raise GateError(f"{label} mismatch") + return { + "containers": len(containers), + "volumes": len(volumes), + "networks": len(networks), + } + + def _validate_resolved_compose_config(self, stack_env: Mapping[str, str]) -> str: + runtime = self._require_runtime() + result = self._run( + "resolved_compose_config", + self._compose_argv("config", "--format", "json"), + timeout=60, + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise GateError("resolved Compose config is not JSON") from exc + services = payload.get("services") + if not isinstance(services, dict) or set(services) != {"db", "api", "web", "proxy"}: + raise GateError("resolved Compose services are not the exact expected set") + + expected_bindings = { + ("db", 5432): runtime.database_port, + ("proxy", 80): runtime.http_port, + ("proxy", 443): runtime.https_port, + } + actual_bindings: dict[tuple[str, int], int] = {} + for service_name, service in services.items(): + if not isinstance(service, dict): + raise GateError("resolved Compose service is not an object") + labels = service.get("labels") or {} + if labels.get("com.vignette.periodic-e2e-sentinel") != runtime.sentinel: + raise GateError("resolved Compose service sentinel mismatch") + for binding in service.get("ports") or []: + if not isinstance(binding, dict): + raise GateError("resolved Compose port is not structured") + host_ip = str(binding.get("host_ip") or "") + if host_ip != "127.0.0.1": + raise GateError("resolved Compose port is not loopback-only") + target = int(binding.get("target") or 0) + published = int(binding.get("published") or 0) + assert_safe_value("resolved Compose published port", str(published)) + actual_bindings[(service_name, target)] = published + if actual_bindings != expected_bindings: + raise GateError("resolved Compose port set differs from the generated plan") + + api_environment = services["api"].get("environment") or {} + if not isinstance(api_environment, dict): + raise GateError("resolved API environment is not structured") + database_url = str(api_environment.get("DATABASE_URL") or "") + assert_safe_value("resolved API DATABASE_URL", database_url) + parsed_database = urlsplit(database_url) + if ( + parsed_database.scheme != "postgresql" + or parsed_database.hostname != "db" + or parsed_database.port != 5432 + or parsed_database.path != f"/{stack_env['POSTGRES_DB']}" + ): + raise GateError("resolved API DATABASE_URL escapes the disposable DB service") + engine_url = str(api_environment.get("ENGINE_URL") or "") + assert_safe_value("resolved API ENGINE_URL", engine_url) + parsed_engine = urlsplit(engine_url) + if ( + parsed_engine.scheme != "http" + or parsed_engine.hostname != "host.docker.internal" + or parsed_engine.port != runtime.engine_port + ): + raise GateError("resolved API ENGINE_URL escapes the disposable engine") + for key in ("FRONTEND_BASE_URL", "OAUTH_REDIRECT_URI"): + value = str(api_environment.get(key) or "") + assert_safe_value(f"resolved API {key}", value) + if urlsplit(value).hostname not in {"127.0.0.1", "localhost"}: + raise GateError(f"resolved API {key} is not loopback") + + volumes = payload.get("volumes") or {} + networks = payload.get("networks") or {} + if len(volumes) != 3 or len(networks) != 1: + raise GateError("resolved Compose resource count is not disposable-exact") + for collection in (volumes, networks): + for resource in collection.values(): + labels = resource.get("labels") if isinstance(resource, dict) else None + if not isinstance(labels, dict) or labels.get( + "com.vignette.periodic-e2e-sentinel" + ) != runtime.sentinel: + raise GateError("resolved Compose resource sentinel mismatch") + return hashlib.sha256(result.stdout.encode("utf-8")).hexdigest() + + def _install_database_sentinel(self, stack_env: Mapping[str, str]) -> str: + runtime = self._require_runtime() + sql = ( + "CREATE TABLE IF NOT EXISTS public.periodic_e2e_sentinel (" + "run_id text PRIMARY KEY, compose_project text NOT NULL, source_head text NOT NULL, " + "source_tree text NOT NULL); " + "INSERT INTO public.periodic_e2e_sentinel VALUES (" + f"'{runtime.run_id}','{runtime.project}','{self.state.source.head}'," + f"'{self.state.source.tree}') ON CONFLICT (run_id) DO NOTHING; " + "SELECT run_id || '|' || compose_project || '|' || source_head || '|' || source_tree " + f"FROM public.periodic_e2e_sentinel WHERE run_id='{runtime.run_id}';" + ) + result = self._run( + "database_sentinel", + self._compose_argv( + "exec", + "-T", + "db", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + stack_env["POSTGRES_USER"], + "-d", + stack_env["POSTGRES_DB"], + "-tAc", + sql, + ), + timeout=60, + ) + expected = ( + f"{runtime.run_id}|{runtime.project}|{self.state.source.head}|" + f"{self.state.source.tree}" + ) + if expected not in result.stdout: + raise GateError("database sentinel round trip mismatch") + return hashlib.sha256(expected.encode("utf-8")).hexdigest() + + def _browser_env( + self, + runtime: RuntimeIdentity, + fixture: Path, + result: Path | None = None, + ) -> dict[str, str]: + values = { + "PLAYWRIGHT_BASE_URL": f"http://127.0.0.1:{runtime.http_port}", + "PLAYWRIGHT_SKIP_WEB_SERVER": "1", + "E2E_RETURNED_PRACTICE_DB_CLOSED_LOOP": "1", + "E2E_RETURNED_PRACTICE_FIXTURE": str(fixture), + "CI": "1", + } + if result is not None: + values["E2E_PERIODIC_LEARNER_REAL_CLOSED_LOOP"] = "1" + values["E2E_PERIODIC_LEARNER_RESULT"] = str(result) + return _clean_child_env(values) + + def _validate_periodic_result(self, result_path: Path) -> dict[str, Any]: + try: + payload = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise GateError(f"periodic browser result is unreadable: {exc}") from exc + if payload.get("schema_version") != "vignette.periodic-learner-real-closed-loop.v1": + raise GateError("periodic browser result schema mismatch") + required_true = ( + "same_learner", + "recommendation_verified", + "session_created_in_browser", + "sse_turn_verified", + "review_ready", + ) + if any(payload.get(key) is not True for key in required_true): + raise GateError("periodic browser proof is incomplete") + for goal in ("g4", "g5"): + value = payload.get(goal) + if not isinstance(value, dict) or ( + value.get("initial_count"), + value.get("first_count"), + value.get("replay_count"), + value.get("replay_idempotent"), + ) != (0, 1, 1, True): + raise GateError(f"{goal} did not prove 0 -> 1 -> 1 idempotency") + return { + "same_learner": True, + "recommendation_verified": True, + "session_created_in_browser": True, + "sse_turn_verified": True, + "review_ready": True, + "g4_counts": [0, 1, 1], + "g5_counts": [0, 1, 1], + "result_sha256": _sha256_path(result_path), + } + + def _cleanup(self) -> dict[str, Any]: + runtime = self.state.runtime + proof: dict[str, Any] = { + "compose_down_exit_code": None, + "engine_exact_pid_stopped": self.state.engine is None, + "temp_removed": self.state.temp_dir is None, + "container_remainder": None, + "volume_remainder": None, + "network_remainder": None, + "listener_counts": {}, + "errors": [], + } + if runtime is None: + return proof + if self.state.stack_attempted and self.state.env_file and self.state.override_file: + try: + down = self._run( + "compose_down", + self._compose_argv("down", "--remove-orphans", "--volumes", "--timeout", "30"), + timeout=600, + check=False, + ) + proof["compose_down_exit_code"] = down.returncode + except BaseException as exc: # cleanup must continue + proof["errors"].append(_safe_error(exc)) + if self.state.engine is not None: + try: + proof["engine_exact_pid_stopped"] = self.controller.stop_exact( + self.state.engine, timeout=15 + ) + except BaseException as exc: + proof["errors"].append(_safe_error(exc)) + for kind in ("container", "volume", "network"): + try: + proof[f"{kind}_remainder"] = len(self._resource_ids(kind)) + except BaseException as exc: + proof["errors"].append(_safe_error(exc)) + for port in runtime.ports: + proof["listener_counts"][str(port)] = int( + self.controller.tcp_listening("127.0.0.1", port) + ) + if self.state.temp_dir is not None: + try: + resolved = self.state.temp_dir.resolve() + expected_parent = Path(tempfile.gettempdir()).resolve() + if resolved.parent != expected_parent or not resolved.name.startswith( + "vignette-periodic-" + ): + raise GateError("refusing to remove a non-runner temp directory") + shutil.rmtree(resolved) + proof["temp_removed"] = not resolved.exists() + except BaseException as exc: + proof["errors"].append(_safe_error(exc)) + return proof + + @staticmethod + def _cleanup_green(cleanup: Mapping[str, Any]) -> bool: + return ( + cleanup.get("compose_down_exit_code") in {None, 0} + and cleanup.get("engine_exact_pid_stopped") is True + and cleanup.get("temp_removed") is True + and cleanup.get("container_remainder") in {None, 0} + and cleanup.get("volume_remainder") in {None, 0} + and cleanup.get("network_remainder") in {None, 0} + and all(value == 0 for value in cleanup.get("listener_counts", {}).values()) + and not cleanup.get("errors") + ) + + def preflight(self) -> dict[str, Any]: + source = self.source_identity(require_clean=False) + runtime = build_runtime_identity(source) + validate_runtime_identity(runtime) + self.state.source = source + self.state.runtime = runtime + docker_target = self._pin_local_docker_context() + if any(self.controller.tcp_listening("127.0.0.1", port) for port in runtime.ports): + raise GateError("an allocated preflight port already has a listener") + temp_dir = Path(tempfile.mkdtemp(prefix=f"vignette-periodic-{runtime.run_id}-")) + self.state.temp_dir = temp_dir + self.state.env_file = temp_dir / "stack.env" + self.state.override_file = temp_dir / "compose.periodic.yml" + try: + stack_env = build_stack_environment(runtime) + write_env_file(self.state.env_file, stack_env) + write_compose_override(self.state.override_file) + resolved_config_sha = self._validate_resolved_compose_config(stack_env) + finally: + shutil.rmtree(temp_dir) + self.state.temp_dir = None + return { + "schema_version": SCHEMA_VERSION, + "status": "PREFLIGHT_ONLY", + "source": {"head": source.head, "tree": source.tree}, + "runtime": { + "project": runtime.project, + "ports": list(runtime.ports), + "protected_ports_reused": False, + "public_or_nas_reference": False, + }, + "resolved_config": { + "sha256": resolved_config_sha, + "ports_loopback_only": True, + "database_service_only": True, + "sentinel_labels_complete": True, + }, + "docker_target": docker_target, + "temp_removed": not temp_dir.exists(), + "automation_changed": False, + } + + def execute(self) -> dict[str, Any]: + started_at = _now_iso() + status = "FAILED" + error = "" + cleanup: dict[str, Any] = {} + try: + self.state.source = self.source_identity(require_clean=True) + self.state.runtime = build_runtime_identity(self.state.source) + runtime = self.state.runtime + validate_runtime_identity(runtime) + self.state.proof["docker_target"] = self._pin_local_docker_context() + if any(self.controller.tcp_listening("127.0.0.1", port) for port in runtime.ports): + raise GateError("an allocated runtime port already has a listener") + + temp_dir = Path(tempfile.mkdtemp(prefix=f"vignette-periodic-{runtime.run_id}-")) + self.state.temp_dir = temp_dir + self.state.env_file = temp_dir / "stack.env" + self.state.override_file = temp_dir / "compose.periodic.yml" + fixture_path = temp_dir / "returned-practice-fixture.json" + periodic_result = temp_dir / "periodic-result.json" + stack_env = build_stack_environment(runtime) + write_env_file(self.state.env_file, stack_env) + write_compose_override(self.state.override_file) + + base_url = f"http://127.0.0.1:{runtime.http_port}" + database_url = ( + f"postgresql://{stack_env['APP_DB_USER']}:{stack_env['APP_DB_PASSWORD']}" + f"@127.0.0.1:{runtime.database_port}/{stack_env['POSTGRES_DB']}" + ) + database_admin_url = ( + f"postgresql://{stack_env['POSTGRES_USER']}:{stack_env['POSTGRES_PASSWORD']}" + f"@127.0.0.1:{runtime.database_port}/{stack_env['POSTGRES_DB']}" + ) + assert_loopback_url("browser base URL", base_url, runtime.http_port) + assert_loopback_url("database URL", database_url, runtime.database_port) + assert_loopback_url("database admin URL", database_admin_url, runtime.database_port) + self.state.proof["resolved_config"] = { + "sha256": self._validate_resolved_compose_config(stack_env), + "ports_loopback_only": True, + "database_service_only": True, + "engine_port_isolated": True, + "sentinel_labels_complete": True, + } + + engine_env = _clean_child_env( + { + "ENGINE_GATEWAY_SHARED_SECRET": stack_env[ + "ENGINE_GATEWAY_SHARED_SECRET" + ], + "ENGINE_CLI_CWD": str(temp_dir / "engine-runtime"), + "PYTHONPATH": str(REPO_ROOT / "apps" / "api"), + "PYTHONUTF8": "1", + } + ) + engine_argv = [ + self.config.python_exe, + "-X", + "utf8", + "-m", + "uvicorn", + "engine_gateway.gateway:app", + "--host", + "127.0.0.1", + "--port", + str(runtime.engine_port), + ] + assert_safe_invocation(engine_argv, engine_env) + self.state.engine = self.controller.start( + "engine_start", + engine_argv, + cwd=REPO_ROOT / "apps" / "api", + env=engine_env, + stdout_path=temp_dir / "engine.stdout.log", + stderr_path=temp_dir / "engine.stderr.log", + ) + engine_health = self._wait_json( + "engine_health", + f"http://127.0.0.1:{runtime.engine_port}/health", + lambda payload: payload.get("ok") is True, + timeout=60, + ) + engine_ready = self._wait_json( + "engine_ready", + f"http://127.0.0.1:{runtime.engine_port}/ready?force=true", + lambda payload: payload.get("ok") is True, + headers={ + "X-Vignette-Engine-Token": stack_env[ + "ENGINE_GATEWAY_SHARED_SECRET" + ] + }, + timeout=self.config.readiness_timeout, + ) + self.state.proof["engine"] = { + "health_ok": engine_health.get("ok") is True, + "ready_ok": engine_ready.get("ok") is True, + "provider": engine_ready.get("engine"), + } + + self.state.stack_attempted = True + self._run( + "compose_up", + self._compose_argv( + "up", + "-d", + "--build", + "--wait", + "--wait-timeout", + str(int(self.config.readiness_timeout)), + ), + timeout=1800, + ) + resources = self._verify_resources_up() + api_health = self._wait_json( + "api_health", + f"{base_url}/api/health", + lambda payload: payload.get("status") == "ok" + and payload.get("db") is True + and payload.get("engine") is True, + timeout=self.config.readiness_timeout, + ) + web_text = self.controller.http_text(base_url, timeout=30) + if '
' not in web_text: + raise GateError("web readiness omitted the React root") + sentinel_sha = self._install_database_sentinel(stack_env) + self.state.proof["stack"] = { + **resources, + "api_db_ready": api_health.get("db") is True, + "api_engine_ready": api_health.get("engine") is True, + "web_ready": True, + "database_sentinel_sha256": sentinel_sha, + } + + harness_env = _clean_child_env( + { + "PYTHONPATH": str(REPO_ROOT / "apps" / "api"), + "PYTHONUTF8": "1", + } + ) + self._run( + "prepare_returned_practice_fixture", + [ + self.config.python_exe, + "-X", + "utf8", + str(FIXTURE_SCRIPT), + "--api-base-url", + f"{base_url}/api", + "--database-url", + database_url, + "--database-admin-url", + database_admin_url, + "--practice-internal-token", + stack_env["VIGNETTE_PRACTICE_INTERNAL_TOKEN"], + "--transfer-internal-token", + stack_env["VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN"], + "--out", + str(fixture_path), + "--request-timeout", + str(self.config.request_timeout), + "--review-poll-timeout", + str(self.config.readiness_timeout), + ], + env=harness_env, + timeout=20 * 60, + ) + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + setup = fixture.get("setup_proof") or {} + expected_setup = { + "source_review_ready": True, + "follow_up_review_ready": True, + "distinct_persona": True, + "initial_runtime_observation_count": 0, + "initial_actual_transfer_execution_count": 0, + } + if any(setup.get(key) != value for key, value in expected_setup.items()): + raise GateError("returned-practice fixture setup proof is incomplete") + self.state.proof["fixture"] = { + **expected_setup, + "fixture_sha256": _sha256_path(fixture_path), + } + + web_dir = REPO_ROOT / "apps" / "web" + playwright_cli = web_dir / "node_modules" / "@playwright" / "test" / "cli.js" + periodic_run = self._run( + "periodic_same_learner_browser", + [ + self.config.node_exe, + str(playwright_cli), + "test", + PERIODIC_SPEC, + "--project=chromium-single-run", + "--workers=1", + "--reporter=line", + ], + cwd=web_dir, + env=self._browser_env(runtime, fixture_path, periodic_result), + timeout=25 * 60, + ) + if not re.search(r"\b1 passed\b", periodic_run.stdout + periodic_run.stderr): + raise GateError("periodic browser did not report exactly 1 passed") + self.state.proof["periodic_browser"] = self._validate_periodic_result( + periodic_result + ) + + returned_run = self._run( + "returned_practice_desktop_mobile", + [ + self.config.node_exe, + str(playwright_cli), + "test", + RETURNED_SPEC, + "--project=chromium-desktop", + "--project=chromium-mobile", + "--workers=1", + "--reporter=line", + ], + cwd=web_dir, + env=self._browser_env(runtime, fixture_path), + timeout=25 * 60, + ) + if not re.search(r"\b4 passed\b", returned_run.stdout + returned_run.stderr): + raise GateError("returned-practice DB gate did not report exactly 4 passed") + self.state.proof["returned_practice_browser"] = { + "desktop_passed": 2, + "mobile_passed": 2, + "total_passed": 4, + "route_mock_count": 0, + "output_sha256": hashlib.sha256( + returned_run.stdout.encode("utf-8") + ).hexdigest(), + } + + final_source = self.source_identity(require_clean=True) + if final_source != self.state.source: + raise GateError("source HEAD/tree changed during the execution") + status = "GREEN" + except BaseException as exc: + error = _safe_error(exc) + finally: + cleanup = self._cleanup() + if not self._cleanup_green(cleanup): + status = "FAILED" + if not error: + error = "exact cleanup proof is not GREEN" + + runtime = self.state.runtime + receipt = { + "schema_version": SCHEMA_VERSION, + "status": status, + "started_at": started_at, + "finished_at": _now_iso(), + "source": ( + {"head": self.state.source.head, "tree": self.state.source.tree} + if self.state.source + else None + ), + "runtime": ( + { + "run_id": runtime.run_id, + "compose_project": runtime.project, + "sentinel_sha256": hashlib.sha256( + runtime.sentinel.encode("utf-8") + ).hexdigest(), + "ports": { + "http": runtime.http_port, + "https": runtime.https_port, + "database": runtime.database_port, + "engine": runtime.engine_port, + }, + "protected_ports_reused": False, + "public_contacted": False, + "nas_contacted": False, + "active_database_contacted": False, + } + if runtime + else None + ), + "proof": self.state.proof, + "stages": self.state.stages, + "cleanup": cleanup, + "automation_changed": False, + "error": error or None, + } + self.config.receipt_path.parent.mkdir(parents=True, exist_ok=True) + temp_receipt = self.config.receipt_path.with_suffix( + self.config.receipt_path.suffix + ".tmp" + ) + temp_receipt.write_text( + json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + temp_receipt.replace(self.config.receipt_path) + return receipt + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--preflight", action="store_true") + mode.add_argument("--execute", action="store_true") + parser.add_argument("--receipt", default="") + parser.add_argument("--python", default=sys.executable) + parser.add_argument("--node", default=shutil.which("node.exe") or "node.exe") + parser.add_argument("--docker", default=shutil.which("docker.exe") or "docker.exe") + parser.add_argument("--request-timeout", type=float, default=240.0) + parser.add_argument("--readiness-timeout", type=float, default=600.0) + args = parser.parse_args(argv) + if args.execute and not args.receipt: + parser.error("--execute requires --receipt") + if args.request_timeout <= 0 or args.readiness_timeout < 60: + parser.error("timeouts must be positive and readiness must be at least 60 seconds") + return args + + +def main(argv: Sequence[str] | None = None) -> int: + raw_argv = list(sys.argv[1:] if argv is None else argv) + try: + assert_safe_invocation(raw_argv, os.environ) + assert_no_inherited_runtime_targets(os.environ) + args = parse_args(raw_argv) + receipt = ( + Path(args.receipt).resolve() + if args.receipt + else Path(tempfile.gettempdir()) / "vignette-periodic-preflight.json" + ) + config = RunnerConfig( + receipt_path=receipt, + python_exe=args.python, + node_exe=args.node, + docker_exe=args.docker, + execute=args.execute, + request_timeout=args.request_timeout, + readiness_timeout=args.readiness_timeout, + ) + runner = PeriodicRunner(config, LocalController()) + result = runner.execute() if args.execute else runner.preflight() + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) + return 0 if result["status"] in {"GREEN", "PREFLIGHT_ONLY"} else 1 + except GateError as exc: + print( + json.dumps( + {"schema_version": SCHEMA_VERSION, "status": "BLOCKED", "error": _safe_error(exc)}, + ensure_ascii=False, + separators=(",", ":"), + ) + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_check_g7_human_voice_gain.py b/scripts/test_check_g7_human_voice_gain.py new file mode 100644 index 0000000..0b123fa --- /dev/null +++ b/scripts/test_check_g7_human_voice_gain.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +API_ROOT = REPO_ROOT / "apps/api" +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + +from app.test_g7_voice_gain_evidence import _valid_payload # noqa: E402 +from scripts.test_g7_external_proof import human_pack # noqa: E402 + + +SCRIPT_PATH = Path(__file__).with_name("check-g7-human-voice-gain.py") +SPEC = importlib.util.spec_from_file_location("check_g7_human_voice_gain", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class G7HumanVoiceGainCliTests(unittest.TestCase): + def test_production_pack_passes_with_aggregate_output_only(self) -> None: + report = MODULE.validate_payload(human_pack()) + + self.assertTrue(report["passed"]) + self.assertEqual([], report["validation_errors"]) + self.assertEqual(30, report["result"]["held_out_participants"]) + serialized = json.dumps(report, ensure_ascii=False) + self.assertNotIn("held-000", serialized) + self.assertNotIn("labeler-001", serialized) + self.assertFalse(report["privacy_boundary"]["participant_keys_logged"]) + + def test_underpowered_pack_fails_with_gate_names_but_no_identifiers(self) -> None: + report = MODULE.validate_payload(_valid_payload()) + + self.assertFalse(report["passed"]) + self.assertIn( + "production_participant_floor", + report["result"]["failure_reasons"], + ) + serialized = json.dumps(report, ensure_ascii=False) + self.assertNotIn("held-out-001", serialized) + self.assertNotIn("labeler-001", serialized) + + def test_invalid_rows_emit_only_json_pointer_and_error_type(self) -> None: + payload = _valid_payload() + observations = payload["observations"] + assert isinstance(observations, list) + labels = observations[0]["labels"] + assert isinstance(labels, list) + labels[0]["category"] = "person@example.test" + + report = MODULE.validate_payload(payload) + + self.assertFalse(report["passed"]) + self.assertEqual({}, report["result"]) + self.assertTrue(report["validation_errors"]) + self.assertEqual( + "/observations/0/labels/0/category", + report["validation_errors"][0]["pointer"], + ) + serialized = json.dumps(report, ensure_ascii=False) + self.assertNotIn("person@example.test", serialized) + self.assertNotIn("held-out-001", serialized) + + def test_cli_does_not_echo_input_path_and_schema_marks_kappa_required(self) -> None: + with tempfile.TemporaryDirectory() as directory: + secret_name = "person-at-example.test.json" + path = Path(directory) / secret_name + path.write_text(json.dumps(_valid_payload()), encoding="utf-8") + output = io.StringIO() + with contextlib.redirect_stdout(output): + exit_code = MODULE.main(["--input", str(path)]) + + self.assertEqual(1, exit_code) + self.assertNotIn(secret_name, output.getvalue()) + + schema = MODULE.G7HumanVoiceGainEvidencePack.model_json_schema() + reliability = schema["$defs"]["G7ReliabilityClaim"] + label = schema["$defs"]["G7HumanAxisLabel"] + self.assertIn("reported_categorical_kappa", reliability["required"]) + self.assertIn("category", label["required"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_g7_external_proof.py b/scripts/test_g7_external_proof.py index 157719b..3c543e1 100644 --- a/scripts/test_g7_external_proof.py +++ b/scripts/test_g7_external_proof.py @@ -44,6 +44,7 @@ def human_pack() -> dict[str, object]: ) observations = [] axes = ("goal", "task", "bond") + categories = ("low", "medium", "high") for session_index in range(50): participant = f"held-{session_index % 30:03d}" for axis_index, axis in enumerate(axes): @@ -60,8 +61,16 @@ def human_pack() -> dict[str, object]: "voice_enabled_status": "observed", "voice_enabled_score": target, "labels": [ - {"labeler_key": "labeler-001", "score": target}, - {"labeler_key": "labeler-002", "score": target}, + { + "labeler_key": "labeler-001", + "score": target, + "category": categories[axis_index], + }, + { + "labeler_key": "labeler-002", + "score": target, + "category": categories[axis_index], + }, ], } ) @@ -108,6 +117,7 @@ def human_pack() -> dict[str, object]: "reliability": { "labeler_keys": ["labeler-001", "labeler-002"], "reported_icc": 1.0, + "reported_categorical_kappa": 1.0, "report_sha256": _sha(112), }, "observations": observations, @@ -432,6 +442,25 @@ class G7ExternalProofTests(unittest.TestCase): self.assertEqual([], errors) self.assertTrue(gain["passed"]) self.assertGreaterEqual(gain["held_out_participants"], 30) + self.assertGreaterEqual(gain["recomputed_categorical_kappa"], 0.70) + + def test_human_pack_cannot_bypass_the_required_categorical_kappa(self) -> None: + errors: list[str] = [] + payload = human_pack() + reliability = payload["reliability"] + assert isinstance(reliability, dict) + reliability.pop("reported_categorical_kappa") + observations = payload["observations"] + assert isinstance(observations, list) + for observation in observations: + labels = observation["labels"] + for label in labels: + label.pop("category") + + gain = self.checker.validate_human_gain(payload, errors) + + self.assertEqual({}, gain) + self.assertIn("human_gain:invalid:ValidationError", errors) def test_windows_host_topology_passes_without_weakening_compose(self) -> None: errors: list[str] = [] diff --git a/scripts/test_outcome_os_release_agent.py b/scripts/test_outcome_os_release_agent.py index 48ce3cb..bee7239 100644 --- a/scripts/test_outcome_os_release_agent.py +++ b/scripts/test_outcome_os_release_agent.py @@ -693,6 +693,15 @@ class OutcomeReleaseAgentTests(unittest.TestCase): set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS) | set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS), ) + self.assertEqual( + set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS), + set(self.agent_module.POSTDEPLOY_NAS_REAL_API_E2E_SPECS) + | set(self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS), + ) + self.assertFalse( + set(self.agent_module.POSTDEPLOY_NAS_REAL_API_E2E_SPECS) + & set(self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS) + ) self.assertLess( runner.event_log.index("runner:postdeploy_browser_review"), runner.event_log.index("deployment:commit_active_state"), @@ -750,6 +759,22 @@ class OutcomeReleaseAgentTests(unittest.TestCase): ["e2e/returned-practice-db-closed-loop.spec.ts"], evidence["browser_review_proof"]["separate_disposable_db_specs"], ) + self.assertEqual( + { + "real_api_db_specs": [ + "e2e/session-layout.spec.ts", + "e2e/session-persistence.spec.ts", + ], + "route_fixture_specs": list( + self.agent_module.POSTDEPLOY_NAS_ROUTE_FIXTURE_E2E_SPECS + ), + }, + evidence["browser_review_proof"]["runtime_scope"], + ) + self.assertEqual( + "not_run_by_release_agent", + evidence["browser_review_proof"]["separate_disposable_db_status"], + ) self.assertEqual( [ "docs/dev_dashboard.html", diff --git a/scripts/test_run_g7_external_proof_window.py b/scripts/test_run_g7_external_proof_window.py index 6315768..74ddd85 100644 --- a/scripts/test_run_g7_external_proof_window.py +++ b/scripts/test_run_g7_external_proof_window.py @@ -1,7 +1,9 @@ from __future__ import annotations import importlib.util +import json import sys +import tempfile import unittest from pathlib import Path from types import SimpleNamespace @@ -50,8 +52,8 @@ def args(**overrides): "microphone_device": "", "confirm_physical_capture": False, "duration_seconds": 3_120.0, - "runtime_interval_seconds": 100.0, - "topology_interval_seconds": 100.0, + "runtime_interval_seconds": 60.0, + "topology_interval_seconds": 60.0, "human_voice_gain": Path("pack.json"), "out_dir": Path("out"), "rehearse": False, @@ -154,6 +156,8 @@ class HostAlignmentTest(unittest.TestCase): self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, parsed.origin) self.assertEqual(MODULE.DEFAULT_ADMIN_RUNTIME_URL, parsed.admin_runtime_url) self.assertEqual(3_120.0, parsed.duration_seconds) + self.assertEqual(60.0, parsed.runtime_interval_seconds) + self.assertEqual(60.0, parsed.topology_interval_seconds) self.assertIsNone(parsed.allowed_browser_origins) help_text = parser.format_help() for expected in ( @@ -168,13 +172,13 @@ class HostAlignmentTest(unittest.TestCase): class SamplePlanTest(unittest.TestCase): def test_samples_cover_the_whole_window(self) -> None: - self.assertEqual(MODULE.sample_plan(3_120.0, 100.0), 33) + self.assertEqual(MODULE.sample_plan(3_120.0, 60.0), 53) def test_fractional_interval_always_gets_a_terminal_sample(self) -> None: - self.assertEqual(MODULE.sample_plan(3_121.0, 100.0), 33) + self.assertEqual(MODULE.sample_plan(3_121.0, 60.0), 54) def test_invalid_plan_fails_closed(self) -> None: - for duration, interval in ((0, 100.0), (3_000.0, 0)): + for duration, interval in ((0, 60.0), (3_000.0, 0)): with self.subTest(duration=duration, interval=interval): with self.assertRaises(MODULE.WindowError): MODULE.sample_plan(duration, interval) @@ -226,6 +230,44 @@ class ConsentGateTest(unittest.TestCase): def test_rehearse_relaxes_only_the_two_human_inputs(self) -> None: MODULE.validate(args(rehearse=True, duration_seconds=30.0, human_voice_gain=None)) + def test_child_sampling_intervals_are_bounded_before_capture(self) -> None: + for field in ("runtime_interval_seconds", "topology_interval_seconds"): + with self.subTest(field=field): + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.validate(args(**{field: 60.001})) + self.assertEqual( + f"{field.removesuffix('_seconds')}_out_of_bounds", + str(ctx.exception), + ) + + def test_human_pack_is_fully_validated_before_capture(self) -> None: + from app.test_g7_voice_gain_evidence import _valid_payload + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + missing = root / "missing.json" + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.validate_human_voice_gain_pack(missing) + self.assertEqual("human_voice_gain_pack_unreadable", str(ctx.exception)) + + malformed = root / "malformed.json" + malformed.write_text("{}", encoding="utf-8") + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.validate_human_voice_gain_pack(malformed) + self.assertEqual("human_voice_gain_pack_invalid", str(ctx.exception)) + + underpowered = root / "underpowered.json" + underpowered.write_text( + json.dumps(_valid_payload()), + encoding="utf-8", + ) + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.validate_human_voice_gain_pack(underpowered) + self.assertTrue( + str(ctx.exception).startswith("human_voice_gain_pack_failed:") + ) + self.assertIn("production_participant_floor", str(ctx.exception)) + class LegCompositionTest(unittest.TestCase): def test_expected_providers_default_to_the_decided_local_stack(self) -> None: diff --git a/scripts/test_run_periodic_learner_e2e.py b/scripts/test_run_periodic_learner_e2e.py new file mode 100644 index 0000000..ddc2eff --- /dev/null +++ b/scripts/test_run_periodic_learner_e2e.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import ModuleType +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("run-periodic-learner-e2e.py") + + +def load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("run_periodic_learner_e2e", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load periodic learner runner") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runner = load_module() + + +class FakeController: + def __init__(self, *, tracked_status: str = "") -> None: + self.tracked_status = tracked_status + self.commands: list[tuple[str, list[str]]] = [] + self.starts: list[tuple[str, list[str]]] = [] + self.stops: list[int] = [] + + def run( + self, + stage, + argv, + *, + cwd, + env=None, + timeout, + check=True, + ): + del cwd, env, timeout, check + self.commands.append((stage, list(argv))) + stdout = "" + if stage == "source_head": + stdout = "1" * 40 + "\n" + elif stage == "source_tree": + stdout = "2" * 40 + "\n" + elif stage == "source_tracked_clean": + stdout = self.tracked_status + elif stage == "source_untracked_inventory": + stdout = "?? apps/api/engine.err.log.bak\n" + elif stage == "docker_context_show": + stdout = "desktop-linux\n" + elif stage == "docker_context_inspect": + stdout = json.dumps( + [ + { + "Endpoints": { + "docker": { + "Host": "npipe:////./pipe/dockerDesktopLinuxEngine" + } + } + } + ] + ) + return runner.CommandResult(0, stdout, "", 0.01) + + def start( + self, + stage, + argv, + *, + cwd, + env, + stdout_path, + stderr_path, + ): + del cwd, env, stdout_path, stderr_path + self.starts.append((stage, list(argv))) + return runner.ProcessHandle(4242) + + def stop_exact(self, handle, *, timeout): + del timeout + self.stops.append(handle.pid) + return True + + def http_json(self, url, *, headers=None, timeout): + del url, headers, timeout + return {"ok": True, "status": "ok", "db": True, "engine": True} + + def http_text(self, url, *, timeout): + del url, timeout + return '
' + + def tcp_listening(self, host, port, *, timeout=0.25): + del host, port, timeout + return False + + def sleep(self, seconds): + del seconds + + +def config(receipt_path: Path) -> runner.RunnerConfig: + return runner.RunnerConfig( + receipt_path=receipt_path, + python_exe="python.exe", + node_exe="node.exe", + docker_exe="docker.exe", + execute=True, + ) + + +class SafetyContractTests(unittest.TestCase): + def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None: + for port in sorted(runner.FORBIDDEN_PORTS): + with self.subTest(port=port): + with self.assertRaises(runner.GateError): + runner.assert_safe_invocation([f"http://127.0.0.1:{port}"], {}) + with self.assertRaises(runner.GateError): + runner.assert_safe_invocation([], {"DATABASE_URL": f"postgresql://x@127.0.0.1:{port}/db"}) + + def test_rejects_public_and_nas_markers(self) -> None: + values = [ + "https://vignette.chanpaca.net", + "https://api-vignette.chanpaca.net/health", + "postgresql://app@100.116.83.60:55433/vignette", + "vignette-preview-20260807", + "vignette-dev-db", + "/volume1/docker/vignette", + ] + for value in values: + with self.subTest(value=value), self.assertRaises(runner.GateError): + runner.assert_safe_value("contract", value) + + def test_rejects_inherited_runtime_targets_even_when_the_port_looks_local(self) -> None: + for key, value in ( + ("DATABASE_URL", "postgresql://app@127.0.0.1:55439/vignette"), + ("ENGINE_URL", "http://127.0.0.1:9199"), + ("PLAYWRIGHT_BASE_URL", "http://127.0.0.1:5199"), + ("COMPOSE_PROJECT_NAME", "some-existing-project"), + ): + with self.subTest(key=key), self.assertRaises(runner.GateError): + runner.assert_no_inherited_runtime_targets({key: value}) + + def test_runtime_identity_and_generated_environment_are_disposable(self) -> None: + source = runner.SourceIdentity("1" * 40, "2" * 40) + with patch.object(runner, "allocate_unique_ports", return_value=(18080, 18443, 15439, 19199)): + runtime = runner.build_runtime_identity(source) + runner.validate_runtime_identity(runtime) + values = runner.build_stack_environment(runtime) + + self.assertEqual(len(set(runtime.ports)), 4) + self.assertTrue(runner.PROJECT_RE.fullmatch(runtime.project)) + self.assertEqual(values["HTTP_PORT"], "18080") + self.assertEqual(values["DB_HOST_PORT"], "15439") + self.assertEqual(values["ENGINE_URL"], "http://host.docker.internal:19199") + self.assertNotIn("vignette.chanpaca.net", json.dumps(values)) + self.assertFalse(set(runtime.ports) & runner.FORBIDDEN_PORTS) + + def test_compose_override_labels_every_resource_and_loopback_binds_database(self) -> None: + with tempfile.TemporaryDirectory() as raw: + path = Path(raw) / "override.yml" + runner.write_compose_override(path) + text = path.read_text(encoding="utf-8") + + self.assertEqual(text.count("com.vignette.periodic-e2e-sentinel"), 8) + self.assertIn('127.0.0.1:${DB_HOST_PORT}:5432', text) + self.assertIn("pgdata:", text) + self.assertIn("apiuploads:", text) + self.assertIn("caddydata:", text) + self.assertIn("vignette:", text) + + def test_resolved_compose_contract_binds_only_loopback_and_internal_db(self) -> None: + runtime = runner.RuntimeIdentity( + "20260809T120000-abcdef12", + "vignette-periodic-11111111-abcdef12", + "periodic:" + "1" * 40 + ":" + "2" * 40 + ":run", + 18080, + 18443, + 15439, + 19199, + ) + stack_env = { + "POSTGRES_DB": "vignette_periodic", + } + payload = { + "services": { + name: { + "labels": { + "com.vignette.periodic-e2e-sentinel": runtime.sentinel + }, + "ports": [], + } + for name in ("db", "api", "web", "proxy") + }, + "volumes": { + name: { + "labels": { + "com.vignette.periodic-e2e-sentinel": runtime.sentinel + } + } + for name in ("pgdata", "apiuploads", "caddydata") + }, + "networks": { + "vignette": { + "labels": { + "com.vignette.periodic-e2e-sentinel": runtime.sentinel + } + } + }, + } + payload["services"]["db"]["ports"] = [ + {"host_ip": "127.0.0.1", "target": 5432, "published": "15439"} + ] + payload["services"]["proxy"]["ports"] = [ + {"host_ip": "127.0.0.1", "target": 80, "published": "18080"}, + {"host_ip": "127.0.0.1", "target": 443, "published": "18443"}, + ] + payload["services"]["api"]["environment"] = { + "DATABASE_URL": "postgresql://app:secret@db:5432/vignette_periodic", + "ENGINE_URL": "http://host.docker.internal:19199", + "FRONTEND_BASE_URL": "http://127.0.0.1:18080", + "OAUTH_REDIRECT_URI": "http://127.0.0.1:18080/api/auth/callback", + } + + class ConfigFake(FakeController): + def run(self, stage, argv, **kwargs): + result = super().run(stage, argv, **kwargs) + if stage == "resolved_compose_config": + return runner.CommandResult(0, json.dumps(payload), "", 0.01) + return result + + fake = ConfigFake() + with tempfile.TemporaryDirectory() as raw: + periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake) + periodic.state.runtime = runtime + periodic.state.docker_context = "desktop-linux" + periodic.state.env_file = Path(raw) / "stack.env" + periodic.state.override_file = Path(raw) / "override.yml" + digest = periodic._validate_resolved_compose_config(stack_env) + + self.assertEqual(len(digest), 64) + payload["services"]["proxy"]["ports"][0]["host_ip"] = "0.0.0.0" + with tempfile.TemporaryDirectory() as raw: + periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), ConfigFake()) + periodic.state.runtime = runtime + periodic.state.docker_context = "desktop-linux" + periodic.state.env_file = Path(raw) / "stack.env" + periodic.state.override_file = Path(raw) / "override.yml" + with self.assertRaises(runner.GateError): + periodic._validate_resolved_compose_config(stack_env) + + def test_dirty_source_fails_before_process_or_compose_mutation(self) -> None: + fake = FakeController(tracked_status=" M apps/web/src/App.tsx\n") + with tempfile.TemporaryDirectory() as raw: + receipt = Path(raw) / "receipt.json" + execution = runner.PeriodicRunner(config(receipt), fake).execute() + stored = json.loads(receipt.read_text(encoding="utf-8")) + + self.assertEqual(execution["status"], "FAILED") + self.assertEqual(stored["status"], "FAILED") + self.assertIn("tracked worktree differs", execution["error"]) + self.assertEqual(fake.starts, []) + self.assertFalse(any("compose" in command for _, command in fake.commands)) + + def test_docker_context_is_pinned_to_local_windows_named_pipe(self) -> None: + fake = FakeController() + periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake) + proof = periodic._pin_local_docker_context() + self.assertEqual(proof, {"context": "desktop-linux", "transport": "npipe", "remote": False}) + self.assertEqual(periodic.state.docker_context, "desktop-linux") + + class RemoteDockerFake(FakeController): + def run(self, stage, argv, **kwargs): + result = super().run(stage, argv, **kwargs) + if stage == "docker_context_inspect": + return runner.CommandResult( + 0, + json.dumps( + [ + { + "Endpoints": { + "docker": {"Host": "tcp://127.0.0.1:2375"} + } + } + ] + ), + "", + 0.01, + ) + return result + + with self.assertRaises(runner.GateError): + runner.PeriodicRunner( + config(Path("receipt.json")), RemoteDockerFake() + )._pin_local_docker_context() + + def test_cleanup_targets_only_exact_project_and_proves_listener_zero(self) -> None: + fake = FakeController() + with tempfile.TemporaryDirectory() as raw: + periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake) + periodic.state.source = runner.SourceIdentity("1" * 40, "2" * 40) + periodic.state.runtime = runner.RuntimeIdentity( + "20260809T120000-abcdef12", + "vignette-periodic-11111111-abcdef12", + "periodic:" + "1" * 40 + ":" + "2" * 40 + ":run", + 18080, + 18443, + 15439, + 19199, + ) + periodic.state.docker_context = "desktop-linux" + periodic.state.env_file = Path(raw) / "stack.env" + periodic.state.override_file = Path(raw) / "override.yml" + periodic.state.stack_attempted = True + periodic.state.engine = runner.ProcessHandle(4242) + proof = periodic._cleanup() + + down = next(command for stage, command in fake.commands if stage == "compose_down") + self.assertIn("vignette-periodic-11111111-abcdef12", down) + self.assertIn("--remove-orphans", down) + self.assertIn("--volumes", down) + self.assertEqual(fake.stops, [4242]) + self.assertEqual(proof["container_remainder"], 0) + self.assertEqual(proof["volume_remainder"], 0) + self.assertEqual(proof["network_remainder"], 0) + self.assertEqual(set(proof["listener_counts"].values()), {0}) + self.assertTrue(periodic._cleanup_green(proof)) + + def test_untracked_runtime_source_is_rejected_but_log_backup_is_not(self) -> None: + fake = FakeController() + periodic = runner.PeriodicRunner(config(Path("receipt.json")), fake) + identity = periodic.source_identity(require_clean=True) + self.assertEqual(identity.head, "1" * 40) + + class DangerousFake(FakeController): + def run(self, stage, argv, **kwargs): + result = super().run(stage, argv, **kwargs) + if stage == "source_untracked_inventory": + return runner.CommandResult( + 0, + "?? apps/web/e2e/uncommitted-runtime.spec.ts\n", + "", + 0.01, + ) + return result + + with self.assertRaises(runner.GateError): + runner.PeriodicRunner( + config(Path("receipt.json")), DangerousFake() + ).source_identity(require_clean=True) + + +if __name__ == "__main__": + unittest.main()