From 506204ddc19372678ca5b8d5d400daa6d7f94341 Mon Sep 17 00:00:00 2001 From: Snazzie Date: Wed, 21 Oct 2026 15:19:11 +0100 Subject: [PATCH 1/2] fix: keep recording maintenance independent Continue background processing when UDP startup fails. Treat inconclusive classification as limited and show neutral completion without attention warnings. Correct persisted ambiguous failures without replaying recordings or changing lap metadata. --- CHANGELOG.md | 2 + apps/backend/src/runtime/boot.ts | 7 ++- .../test/e2e/classification-migration.test.ts | 45 ++++++++++++----- .../backend/test/e2e/database-upgrade.test.ts | 50 +++++++++++++++++-- client/messages/de.json | 2 +- client/messages/en.json | 2 +- client/messages/es.json | 2 +- client/messages/fi.json | 2 +- client/messages/fr.json | 2 +- client/messages/hi.json | 2 +- client/messages/it.json | 2 +- client/messages/ja.json | 2 +- client/messages/ko.json | 2 +- client/messages/nl.json | 2 +- client/messages/pl.json | 2 +- client/messages/pt-BR.json | 2 +- client/messages/pt-PT.json | 2 +- client/messages/ru.json | 2 +- client/messages/uk.json | 2 +- client/messages/zh-CN.json | 2 +- .../plans/valid-non-pace-laps-backend-plan.md | 4 +- docs/plans/valid-non-pace-laps-contract.md | 22 ++++---- docs/plans/valid-non-pace-laps-ui-plan.md | 4 +- .../sessions/classification-migration.spec.ts | 9 ++-- .../classification-migration-worker.ts | 14 +++--- .../classification-migration.ts | 7 ++- 26 files changed, 139 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d20029b..22622b227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - Provide a Linux x86_64 AppImage for Ubuntu 22.04 and newer, with AppImageUpdate-compatible GitHub release deltas. ### Fixes +- Keep recording splitting and background processing available when the UDP telemetry port is already in use. +- Report inconclusive historical lap classification as limited without requesting attention, and correct earlier results. - Keep Spectate racing lines visible when zooming out to the full circuit. - Distinguish native fractional LMU brake and clutch inputs from normalized control readings in the telemetry catalog. - Keep unchanged LMU supplemental snapshots available while successful polling continues, without resetting their original observation ages. diff --git a/apps/backend/src/runtime/boot.ts b/apps/backend/src/runtime/boot.ts index 28b3d9259..f746f2ba9 100644 --- a/apps/backend/src/runtime/boot.ts +++ b/apps/backend/src/runtime/boot.ts @@ -155,7 +155,11 @@ export async function bootServer(options: BootOptions = {}): Promise { +test("missing source completes with limitations without retries or metadata loss; feature Retry durably requeues", async () => { const directory = await mkdtemp(join(tmpdir(), "raceiq-classification-missing-")); const dataDir = join(directory, "data"); let server: UpgradeServer | undefined; @@ -306,7 +307,7 @@ test("missing source is visible attention without retries or metadata loss; feat const running = await openUpgrade(dataDir); server = running.server; const first = await settled(running.address); - expect(first.status).toBe("attention"); + expect(first.status).toBe("complete"); expect(first.limited).toBeGreaterThan(0); expect(first.failed).toBe(0); expect(database.prepare("SELECT classification,is_valid,invalid_reason FROM laps ORDER BY id LIMIT 1").get()).toMatchObject({ classification: "out_lap", is_valid: 0, invalid_reason: "outlap" }); @@ -411,7 +412,7 @@ for (const barrier of ["beforeTransaction", "insideTransaction", "afterCommitBef } for (const defect of ["ambiguous_match", "corrupt_source"] as const) { - test(`${defect} fails the recording atomically without changing saved lap identity`, async () => { + test(`${defect} preserves saved laps and distinguishes limitations from execution failures`, async () => { const directory = await mkdtemp(join(tmpdir(), "raceiq-classification-failure-")); const dataDir = join(directory, "data"); let server: UpgradeServer | undefined; @@ -446,16 +447,36 @@ for (const defect of ["ambiguous_match", "corrupt_source"] as const) { server = running.server; // Corrupt historical input blocks startup and therefore aggregate publication. const snapshot = await settled(running.address, defect !== "corrupt_source"); - expect(snapshot.status).toBe(defect === "corrupt_source" ? "running" : "attention"); - expect(snapshot.failed).toBeGreaterThan(0); + expect(snapshot.status).toBe(defect === "corrupt_source" ? "running" : "complete"); + expect(snapshot.failed).toBe(defect === "corrupt_source" ? 1 : 0); + expect(snapshot.limited).toBe(defect === "ambiguous_match" ? 1 : 0); if (defect === "corrupt_source") expect(snapshot.aggregatesPending).toBe(true); const response = await fetch(`${running.address}/api/sessions/classification-migration-recordings`); expect(response.status).toBe(200); const list = await response.json() as { recordings: Array<{ state: string; reason: string }> }; - expect(list.recordings.some((recording) => recording.state === "failed" && recording.reason === defect)).toBe(true); + expect(list.recordings).toContainEqual(expect.objectContaining({ + state: defect === "ambiguous_match" ? "limited" : "failed", reason: defect, + })); expect(preservedLaps(database)).toEqual(original); expect(database.prepare("SELECT id,is_valid,invalid_reason FROM laps ORDER BY id").all()).toEqual(quality); expect(database.prepare("SELECT id FROM laps WHERE classification IS NOT NULL").all()).toEqual([]); + if (defect === "ambiguous_match") { + const attempts = database.prepare("SELECT recording_id,attempt FROM classification_migration_recordings ORDER BY recording_id").all(); + const revision = (await status(running.address)).revision; + await server.stop(); + database.exec("UPDATE classification_migration_recordings SET state='failed' WHERE reason='ambiguous_match'"); + const resumed = await openUpgrade(dataDir); + server = resumed.server; + const repaired = await settled(resumed.address); + expect(repaired.failed).toBe(0); + expect(repaired.limited).toBe(1); + expect(repaired.status).toBe("complete"); + expect(repaired.revision).toBeGreaterThan(revision); + expect(database.prepare("SELECT recording_id,attempt FROM classification_migration_recordings ORDER BY recording_id").all()).toEqual(attempts); + expect(preservedLaps(database)).toEqual(original); + expect(database.prepare("SELECT id,is_valid,invalid_reason FROM laps ORDER BY id").all()).toEqual(quality); + expect(database.prepare("SELECT id FROM laps WHERE classification IS NOT NULL").all()).toEqual([]); + } } finally { await server?.stop(); database?.close(); @@ -591,9 +612,9 @@ test("#222 scalar-matching capture preserves ambiguous historical boundaries wit const running = await openUpgrade(dataDir); server = running.server; const snapshot = await settled(running.address); - expect(snapshot.status).toBe("attention"); - expect(snapshot.limited).toBe(1); - expect(snapshot.failed).toBe(1); + expect(snapshot.status).toBe("complete"); + expect(snapshot.limited).toBe(2); + expect(snapshot.failed).toBe(0); expect(database.prepare("SELECT id FROM laps WHERE classification='grid_start'").all()).toEqual([]); expect(database.prepare("SELECT COUNT(*) AS count FROM laps WHERE classification IS NULL").get()).toMatchObject({ count: 5 }); expect(preservedLaps(database)).toEqual(original); @@ -601,7 +622,7 @@ test("#222 scalar-matching capture preserves ambiguous historical boundaries wit const response = await fetch(`${running.address}/api/sessions/classification-migration-recordings`); const list = await response.json() as { recordings: Array<{ state: string; reason: string }> }; // Its first stored range differs from current detection; four later exact matches cannot justify a partial commit. - expect(list.recordings).toContainEqual(expect.objectContaining({ state: "failed", reason: "ambiguous_match" })); + expect(list.recordings).toContainEqual(expect.objectContaining({ state: "limited", reason: "ambiguous_match" })); expect(list.recordings).toContainEqual(expect.objectContaining({ state: "limited", reason: "missing_source" })); const firstPageResponse = await fetch(`${running.address}/api/sessions/classification-migration-recordings?limit=1`); const firstPage = await firstPageResponse.json() as { recordings: Array<{ recordingId: string }>; nextCursor: string | null }; diff --git a/apps/backend/test/e2e/database-upgrade.test.ts b/apps/backend/test/e2e/database-upgrade.test.ts index d4fb3d007..9255bd755 100644 --- a/apps/backend/test/e2e/database-upgrade.test.ts +++ b/apps/backend/test/e2e/database-upgrade.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import Database from "libsql"; import { createHash } from "node:crypto"; +import dgram from "node:dgram"; import { LAP_DETECTOR_AC_EVO_ID } from "@raceiq/game-ac-evo/lap-detector"; import { CLASSIFICATION_MIGRATION_VERSION } from "@raceiq/backend-core/session-capture/classification-migration"; import { LAP_DETECTOR_ID } from "@raceiq/backend-core/lap-detection/detector"; @@ -48,7 +49,7 @@ async function command(args: string[], cwd = ROOT, env = process.env) { return stdout; } -function startBranch(dataDir: string, pauseStage: "historical" | "lmu-identity" | "race-results" | "dashboard" | null = null): StartupBackend { +function startBranch(dataDir: string, pauseStage: "historical" | "lmu-identity" | "race-results" | "dashboard" | null = null, udpPort = 0): StartupBackend { const proc = Bun.spawn([process.execPath, "--eval", ` import { captureConsole } from "./server/runtime/logger"; import { bootServer } from "./apps/backend/src/runtime/boot"; @@ -78,7 +79,7 @@ function startBranch(dataDir: string, pauseStage: "historical" | "lmu-identity" console.log("[E2E] Stage control:", control.port); } captureConsole(); - const backend = await bootServer({ httpPort: 0, udpPort: 0, recordingGameId: null, testDependencies }); + const backend = await bootServer({ httpPort: 0, udpPort: ${udpPort}, recordingGameId: null, testDependencies }); const authSetup = await fetch("http://127.0.0.1:" + backend.httpServer.port + "/api/auth/setup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: null, exposureConfirmed: true }), }); @@ -309,7 +310,7 @@ test("startup preserves ambiguous historical Tourist classification without chan expect(database.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(sessionsBeforeClassification); expect(database.prepare("SELECT * FROM laps ORDER BY id").all()).toEqual(lapsBeforeClassification); expect(outcomes.some((row) => row.state === "failed" || row.state === "limited")).toBe(true); - expect(outcomes).toContainEqual({ state: "failed", reason: "ambiguous_match" }); + expect(outcomes).toContainEqual({ state: "limited", reason: "ambiguous_match" }); const resumedTimes = database.prepare("SELECT lap_time FROM laps ORDER BY id").all() as Array<{ lap_time: number }>; expect(resumedTimes.map((lap) => lap.lap_time)).toEqual(lapMetadata.map(() => 123.456)); expect(database.prepare("SELECT id,session_id,notes,is_favorite,profile_id,car_setup,tune_id,experiment_id,experiment_version_id FROM laps ORDER BY id").all()).toEqual(lapMetadata); @@ -490,6 +491,49 @@ for (const journalState of ["unfinished", "malformed", "unreadable"] as const) { }, 60_000); } +test.each([true, false])("recording splitting completes with UDP conflict %s", async (conflict) => { + const temporary = mkdtempSync(join(tmpdir(), "raceiq-udp-conflict-")); + const dataDir = join(temporary, "data"); + const occupied = dgram.createSocket("udp4"); + let backend: StartupBackend | undefined; + try { + const listening = Promise.withResolvers(); + occupied.once("error", listening.reject); + occupied.bind(0, "0.0.0.0", listening.resolve); + await listening.promise; + const port = conflict ? occupied.address().port : 0; + await command([process.execPath, "run", "db:seed", "--games=fm-2023"], ROOT, environment(dataDir)); + backend = startBranch(dataDir, null, port); + if (!conflict) await backend.waitFor("[UDP] Listening on"); + const initial = await backend.request("/api/storage/split-migration"); + expect(initial.status).toBe(200); + expect(await initial.json()).toMatchObject({ + sessionCount: 3, progress: { status: "idle", done: 0, total: 3 }, + }); + const confirmed = await backend.request("/api/storage/split-migration", { + method: "POST", headers: { "content-type": "application/json" }, body: "{}", + }); + expect(confirmed.status).toBe(202); + const deadline = Date.now() + 30_000; + let progress; + do { + const response = await backend.request("/api/storage/split-migration"); + expect(response.status).toBe(200); + progress = await response.json() as { sessionCount: number; progress: { status: string; done: number; total: number; migrated: number; failed: number } }; + if (progress.progress.status !== "running") break; + // The separate backend process owns the real split-queue timer. + await Bun.sleep(100); + } while (Date.now() < deadline); + expect(progress).toMatchObject({ + sessionCount: 0, progress: { status: "success", done: 3, total: 3, migrated: 3, failed: 0 }, + }); + } finally { + await backend?.stop(); + occupied.close(); + await rm(temporary, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); + for (const captureFailure of ["missing", "corrupt"] as const) { test(`${captureFailure} historical capture preserves pending repairs while HTTP remains available`, async () => { const temporary = mkdtempSync(join(tmpdir(), "raceiq-startup-blocked-")); diff --git a/client/messages/de.json b/client/messages/de.json index 28f55cd17..f5508904d 100644 --- a/client/messages/de.json +++ b/client/messages/de.json @@ -2483,7 +2483,7 @@ "storage_recording_summary": "{files} Aufnahmedateien · {size}", "classification_migration_title": "Historische Rundenklassifizierung", "classification_migration_historical_caveat": "Historische Pace-Ergebnisse können sich bei der Neuverarbeitung ändern.", - "classification_migration_complete": "Historische Aufzeichnungen klassifiziert.", + "classification_migration_complete": "Die Klassifizierung historischer Runden ist abgeschlossen.", "classification_migration_attention": "Einige Aufzeichnungen benötigen Aufmerksamkeit; abgeschlossene Klassifizierungen bleiben verfügbar.", "classification_migration_counts": "{completed} klassifiziert · {pending} ausstehend · {limited} eingeschränkt · {failed} fehlgeschlagen", "classification_migration_refresh": "Aktualisieren", diff --git a/client/messages/en.json b/client/messages/en.json index df1d4081c..b36873cfb 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -2484,7 +2484,7 @@ "analyse_motec_unsupported_game": "MoTeC import is not supported for {game} yet. Use a native RaceIQ recording instead.", "classification_migration_title": "Historical lap classification", "classification_migration_historical_caveat": "Historical pace results may change while recordings are reprocessed.", - "classification_migration_complete": "Historical recordings classified.", + "classification_migration_complete": "Historical lap classification finished.", "classification_migration_attention": "Some recordings need attention; completed classification remains available.", "classification_migration_counts": "{completed} classified · {pending} pending · {limited} limited · {failed} failed", "classification_migration_refresh": "Refresh", diff --git a/client/messages/es.json b/client/messages/es.json index ad3dacffc..f1d98e106 100644 --- a/client/messages/es.json +++ b/client/messages/es.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} archivos de grabación · {size}", "classification_migration_title": "Clasificación de vueltas históricas", "classification_migration_historical_caveat": "Los resultados de ritmo históricos pueden cambiar al reprocesar las grabaciones.", - "classification_migration_complete": "Grabaciones históricas clasificadas.", + "classification_migration_complete": "La clasificación de vueltas históricas ha finalizado.", "classification_migration_attention": "Algunas grabaciones requieren atención; las clasificaciones completadas siguen disponibles.", "classification_migration_counts": "{completed} clasificadas · {pending} pendientes · {limited} limitadas · {failed} fallidas", "classification_migration_refresh": "Actualizar", diff --git a/client/messages/fi.json b/client/messages/fi.json index 8d28292d0..edd85271c 100644 --- a/client/messages/fi.json +++ b/client/messages/fi.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} tallennetiedostoa · {size}", "classification_migration_title": "Historiallisten kierrosten luokittelu", "classification_migration_historical_caveat": "Historialliset vauhtitulokset voivat muuttua tallenteiden käsittelyn aikana.", - "classification_migration_complete": "Historialliset tallenteet luokiteltu.", + "classification_migration_complete": "Historiallisten kierrosten luokittelu on valmis.", "classification_migration_attention": "Osa tallenteista vaatii huomiota; valmiit luokitukset säilyvät.", "classification_migration_counts": "{completed} luokiteltu · {pending} odottaa · {limited} rajoitettu · {failed} epäonnistui", "classification_migration_refresh": "Päivitä", diff --git a/client/messages/fr.json b/client/messages/fr.json index 61b827b65..e4b45a352 100644 --- a/client/messages/fr.json +++ b/client/messages/fr.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} fichiers d’enregistrement · {size}", "classification_migration_title": "Classification des tours historiques", "classification_migration_historical_caveat": "Les résultats de rythme historiques peuvent changer pendant le retraitement.", - "classification_migration_complete": "Enregistrements historiques classés.", + "classification_migration_complete": "La classification des tours historiques est terminée.", "classification_migration_attention": "Certains enregistrements nécessitent votre attention.", "classification_migration_counts": "{completed} classés · {pending} en attente · {limited} limités · {failed} en échec", "classification_migration_refresh": "Actualiser", diff --git a/client/messages/hi.json b/client/messages/hi.json index 0c2226fe8..47d61b5bf 100644 --- a/client/messages/hi.json +++ b/client/messages/hi.json @@ -2633,7 +2633,7 @@ "streaming_gap_to_leader": "लीडर से अंतर", "classification_migration_title": "ऐतिहासिक लैप वर्गीकरण", "classification_migration_historical_caveat": "रिकॉर्डिंग दोबारा संसाधित होने पर पुराने पेस परिणाम बदल सकते हैं।", - "classification_migration_complete": "ऐतिहासिक रिकॉर्डिंग वर्गीकृत की गईं।", + "classification_migration_complete": "ऐतिहासिक लैप का वर्गीकरण पूरा हुआ।", "classification_migration_attention": "कुछ रिकॉर्डिंग पर ध्यान देना आवश्यक है; पूरा हुआ वर्गीकरण उपलब्ध है।", "classification_migration_counts": "{completed} वर्गीकृत · {pending} लंबित · {limited} सीमित · {failed} विफल", "classification_migration_refresh": "रीफ़्रेश करें", diff --git a/client/messages/it.json b/client/messages/it.json index f32ce20d7..54bd0d026 100644 --- a/client/messages/it.json +++ b/client/messages/it.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} file di registrazione · {size}", "classification_migration_title": "Classificazione giri storici", "classification_migration_historical_caveat": "I risultati storici di ritmo possono cambiare durante la rielaborazione.", - "classification_migration_complete": "Registrazioni storiche classificate.", + "classification_migration_complete": "La classificazione dei giri storici è terminata.", "classification_migration_attention": "Alcune registrazioni richiedono attenzione.", "classification_migration_counts": "{completed} classificate · {pending} in attesa · {limited} limitate · {failed} non riuscite", "classification_migration_refresh": "Aggiorna", diff --git a/client/messages/ja.json b/client/messages/ja.json index 8e1d5a550..abb3f6e78 100644 --- a/client/messages/ja.json +++ b/client/messages/ja.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "録画ファイル {files} 件 · {size}", "classification_migration_title": "過去のラップ分類", "classification_migration_historical_caveat": "録画の再処理中に過去のペース結果が変わる場合があります。", - "classification_migration_complete": "過去の録画を分類しました。", + "classification_migration_complete": "過去のラップの分類処理が完了しました。", "classification_migration_attention": "対応が必要な録画があります。", "classification_migration_counts": "分類済み {completed} · 待機中 {pending} · 制限あり {limited} · 失敗 {failed}", "classification_migration_refresh": "更新", diff --git a/client/messages/ko.json b/client/messages/ko.json index 66494e7b8..e82e1b261 100644 --- a/client/messages/ko.json +++ b/client/messages/ko.json @@ -2631,7 +2631,7 @@ "streaming_gap_to_leader": "리더와의 격차", "classification_migration_title": "과거 랩 분류", "classification_migration_historical_caveat": "녹화 파일을 다시 처리하는 동안 과거 페이스 결과가 변경될 수 있습니다.", - "classification_migration_complete": "과거 녹화 파일을 분류했습니다.", + "classification_migration_complete": "과거 랩 분류 작업이 완료되었습니다.", "classification_migration_attention": "일부 녹화 파일에 확인이 필요하지만 완료된 분류는 계속 사용할 수 있습니다.", "classification_migration_counts": "{completed}개 분류됨 · {pending}개 대기 · {limited}개 제한됨 · {failed}개 실패", "classification_migration_refresh": "새로고침", diff --git a/client/messages/nl.json b/client/messages/nl.json index fb5436ef6..25ffc0c77 100644 --- a/client/messages/nl.json +++ b/client/messages/nl.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} opnamebestanden · {size}", "classification_migration_title": "Historische rondes classificeren", "classification_migration_historical_caveat": "Historische raceresultaten kunnen wijzigen tijdens het opnieuw verwerken.", - "classification_migration_complete": "Historische opnamen geclassificeerd.", + "classification_migration_complete": "De classificatie van historische ronden is voltooid.", "classification_migration_attention": "Sommige opnamen vereisen aandacht.", "classification_migration_counts": "{completed} geclassificeerd · {pending} in wachtrij · {limited} beperkt · {failed} mislukt", "classification_migration_refresh": "Vernieuwen", diff --git a/client/messages/pl.json b/client/messages/pl.json index a3b76808c..be87a342b 100644 --- a/client/messages/pl.json +++ b/client/messages/pl.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} plików nagrań · {size}", "classification_migration_title": "Klasyfikacja historycznych okrążeń", "classification_migration_historical_caveat": "Historyczne wyniki tempa mogą się zmienić podczas ponownego przetwarzania.", - "classification_migration_complete": "Sklasyfikowano historyczne nagrania.", + "classification_migration_complete": "Klasyfikacja historycznych okrążeń została zakończona.", "classification_migration_attention": "Niektóre nagrania wymagają uwagi.", "classification_migration_counts": "Sklasyfikowane: {completed} · oczekujące: {pending} · ograniczone: {limited} · nieudane: {failed}", "classification_migration_refresh": "Odśwież", diff --git a/client/messages/pt-BR.json b/client/messages/pt-BR.json index d8f134c5b..cec123909 100644 --- a/client/messages/pt-BR.json +++ b/client/messages/pt-BR.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} arquivos de gravação · {size}", "classification_migration_title": "Classificação de voltas históricas", "classification_migration_historical_caveat": "Os resultados históricos de ritmo podem mudar durante o reprocessamento.", - "classification_migration_complete": "Gravações históricas classificadas.", + "classification_migration_complete": "A classificação das voltas históricas foi concluída.", "classification_migration_attention": "Algumas gravações precisam de atenção.", "classification_migration_counts": "{completed} classificadas · {pending} pendentes · {limited} limitadas · {failed} com falha", "classification_migration_refresh": "Atualizar", diff --git a/client/messages/pt-PT.json b/client/messages/pt-PT.json index 9bcdb9ca5..d71730d2e 100644 --- a/client/messages/pt-PT.json +++ b/client/messages/pt-PT.json @@ -2633,7 +2633,7 @@ "streaming_gap_to_leader": "Diferença para o líder", "classification_migration_title": "Classificação de voltas históricas", "classification_migration_historical_caveat": "Os resultados históricos de ritmo podem mudar durante o reprocessamento das gravações.", - "classification_migration_complete": "Gravações históricas classificadas.", + "classification_migration_complete": "A classificação das voltas históricas foi concluída.", "classification_migration_attention": "Algumas gravações precisam de atenção; a classificação concluída continua disponível.", "classification_migration_counts": "{completed} classificadas · {pending} pendentes · {limited} limitadas · {failed} falhadas", "classification_migration_refresh": "Atualizar", diff --git a/client/messages/ru.json b/client/messages/ru.json index 514ec24ec..e670a4c90 100644 --- a/client/messages/ru.json +++ b/client/messages/ru.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} файлов записей · {size}", "classification_migration_title": "Классификация исторических кругов", "classification_migration_historical_caveat": "Исторические результаты темпа могут измениться при повторной обработке.", - "classification_migration_complete": "Исторические записи классифицированы.", + "classification_migration_complete": "Классификация исторических кругов завершена.", "classification_migration_attention": "Некоторые записи требуют внимания.", "classification_migration_counts": "Классифицировано: {completed} · ожидает: {pending} · ограничено: {limited} · ошибок: {failed}", "classification_migration_refresh": "Обновить", diff --git a/client/messages/uk.json b/client/messages/uk.json index e91fe13e2..ec7814f62 100644 --- a/client/messages/uk.json +++ b/client/messages/uk.json @@ -2467,7 +2467,7 @@ "storage_recording_summary": "{files} файлів записів · {size}", "classification_migration_title": "Класифікація історичних кіл", "classification_migration_historical_caveat": "Історичні результати темпу можуть змінитися під час повторної обробки.", - "classification_migration_complete": "Історичні записи класифіковано.", + "classification_migration_complete": "Класифікацію історичних кіл завершено.", "classification_migration_attention": "Деякі записи потребують уваги.", "classification_migration_counts": "Класифіковано: {completed} · очікує: {pending} · обмежено: {limited} · помилок: {failed}", "classification_migration_refresh": "Оновити", diff --git a/client/messages/zh-CN.json b/client/messages/zh-CN.json index 5813a312d..f441e1ee0 100644 --- a/client/messages/zh-CN.json +++ b/client/messages/zh-CN.json @@ -2631,7 +2631,7 @@ "streaming_gap_to_leader": "与领跑者的差距", "classification_migration_title": "历史圈速分类", "classification_migration_historical_caveat": "重新处理录制文件时,历史节奏成绩可能会发生变化。", - "classification_migration_complete": "历史录制文件已完成分类。", + "classification_migration_complete": "历史圈速分类处理已完成。", "classification_migration_attention": "部分录制文件需要处理;已完成的分类仍可查看。", "classification_migration_counts": "已分类 {completed} · 待处理 {pending} · 受限 {limited} · 失败 {failed}", "classification_migration_refresh": "刷新", diff --git a/docs/plans/valid-non-pace-laps-backend-plan.md b/docs/plans/valid-non-pace-laps-backend-plan.md index 7137a6313..e9b5b995e 100644 --- a/docs/plans/valid-non-pace-laps-backend-plan.md +++ b/docs/plans/valid-non-pace-laps-backend-plan.md @@ -35,7 +35,9 @@ All acceptance definitions and setup/action/assertion scenarios are in the [shar | BE-04: visible source limitations, bounded scheduling, safe concurrent work | AC-05/07/08 | E2E-04/05 | | BE-05: contract-conforming real status/retry and correct pace consumers | AC-03/09 | E2E-06/07 | -Native missing/stale state, rolling starts, unknown sessions, and cross-lap evidence use positive-evidence/reset rules (E2E-02). Ambiguous matches/corruption roll back the recording; missing source/evidence is a visible limitation; other work continues without unchanged-input retry loops (E2E-04). Active captures defer; shared-file grouping and maintenance/revision coordination prevent stale overwrites (E2E-05). Precommit crashes roll back; postcommit crashes leave durable aggregate dirty work, not a completed-but-stale dashboard (E2E-03). Nullable legacy metadata remains compatible, not falsely classified (E2E-01/07). +Native missing/stale state, rolling starts, unknown sessions, and cross-lap evidence use positive-evidence/reset rules (E2E-02). Ambiguous matches and unknown classifications are visible limitations, not execution failures; preserve the whole recording without partial updates. Corruption and execution/read/parse errors remain failed. Reconcile previously persisted ambiguous failures to limited without replay or metadata changes, advancing the status revision (AC-07, E2E-04). Other work continues without unchanged-input retry loops. Active captures defer; shared-file grouping and maintenance/revision coordination prevent stale overwrites (E2E-05). Precommit crashes roll back; postcommit crashes leave durable aggregate dirty work, not a completed-but-stale dashboard (E2E-03). Nullable legacy metadata remains compatible, not falsely classified (E2E-01/07). +Aggregate status is `complete` when processing and aggregate refresh have settled with only completed/limited outcomes; reserve `attention` for actual execution failures. Verify both cases through the status API and real UI (AC-09, E2E-04/06). + ## Verification and completion dependency diff --git a/docs/plans/valid-non-pace-laps-contract.md b/docs/plans/valid-non-pace-laps-contract.md index 767f14859..51a58b9b0 100644 --- a/docs/plans/valid-non-pace-laps-contract.md +++ b/docs/plans/valid-non-pace-laps-contract.md @@ -34,10 +34,10 @@ Persist requested classification version, source/result revision, attempt, outco | `pending` | Awaiting worker or finalized source/maintenance ownership; enters `running` when safe. | | `running` | One owner parses the capture with full context; crash returns it to `pending` on startup. | | `completed` | Metadata and durable aggregate invalidation committed for the current version/source revision; unchanged work is skipped. | -| `limited` | Missing source or insufficient native evidence; preserved reliable legacy metadata, visible reason; no automatic retry of unchanged input. | -| `failed` | Corrupt source, ambiguous matching, or processing failure; no partial metadata changes; no automatic retry of unchanged input. | +| `limited` | The run cannot establish a safe classification or exact historical match, or the source is missing; preserve reliable legacy metadata and unclassified laps, expose the reason, and do not automatically retry unchanged input. | +| `failed` | The run fails to execute because of corrupt input or a processing/read/parse error; no partial metadata changes and no automatic retry of unchanged input. An unclassified result or ambiguous historical match is not an execution failure. | -New classification versions enqueue historical work again. Repaired source or explicit retry can move `limited`/`failed` to `pending`. Repeated retry while pending/running is idempotent. A current completed result is not downgraded by duplicate requests. Reprocessing/import/source replacement must invalidate obsolete version/outcome markers and coordinate through the existing capture maintenance lock plus a source/result revision check. If evidence changes during work, discard computed changes and requeue; never overwrite newer results. Shared-file session grouping and matching use recording/frame ranges plus session boundaries, never lap ordinal alone. Any ambiguous/unmatched historical lap fails the entire recording without destructive replacement. +New classification versions enqueue historical work again. Repaired source or explicit retry can move `limited`/`failed` to `pending`. Repeated retry while pending/running is idempotent. A current completed result is not downgraded by duplicate requests. Reprocessing/import/source replacement must invalidate obsolete version/outcome markers and coordinate through the existing capture maintenance lock plus a source/result revision check. If evidence changes during work, discard computed changes and requeue; never overwrite newer results. Shared-file session grouping and matching use recording/frame ranges plus session boundaries, never lap ordinal alone. Any ambiguous/unmatched historical lap makes the recording limited without partial metadata changes or destructive replacement. Inventory reconciliation corrects previously persisted `failed`/`ambiguous_match` outcomes to `limited` without replaying input, incrementing attempts, or changing lap metadata; it advances the durable status revision. Default to one background recording worker, yielding between bounded frame batches and giving live capture priority; stream input instead of materializing the library. Do not hold a DB transaction across decoding. Commit only classification and justified validity/reason changes after quality reassessment; preserve IDs, timing, user decisions, notes, favorites, ownership, associations, raw offsets, indexes, and unrelated analysis. @@ -73,7 +73,7 @@ type ClassificationMigrationRecording = { }; ``` -`total = pending + running + completed + limited + failed`. `idle` means total zero; `running` means pending/running work or dirty aggregates remain; `attention` means no work remains but limited/failed counts are nonzero; otherwise `complete`. Counts describe the current version's discovered historical inventory, not fresh live laps. `complete` never means all native signals are supported; unsupported evidence is `attention`, not successful classification. Deleted inventory is removed consistently from counters. `aggregatesPending` remains true until durable refresh catches up. +`total = pending + running + completed + limited + failed`. `idle` means total zero; `running` means pending/running work or dirty aggregates remain; `attention` means no work remains and execution failures are nonzero; otherwise `complete`. Limited or unclassified results are valid terminal outcomes and must not request user attention. `complete` describes finished processing, not proof that every lap has a category. Counts describe the current version's discovered historical inventory, not fresh live laps. Deleted inventory is removed consistently from counters. `aggregatesPending` remains true until durable refresh catches up. | Operation | Request | Success | Errors | | --- | --- | --- | --- | @@ -91,7 +91,7 @@ Example partial outcome: `{ "version": 1, "revision": 12, "status": "attention", Follow [background-operation composition](../contributing/background-operation-flow.md), not sparse conversion's consent requirement. Mount a feature-owned non-modal dock in the app shell and persistent inline details in `ProcessingMaintenance`. Recording-layout migration has first dock priority, sparse conversion second, classification third; suppression never stops work or hides inline status. Do not add a global queue abstraction. -Pending/running copy says historical pace may change; limited/failed copy distinguishes source limitations from success. Show classified, limited, failed, and pending counts separately. Terminal dock can be dismissed locally without clearing authoritative state; unresolved inline details and retry remain available. Use Paraglide in every supported locale, accessible status/alert text, keyboard controls, and viewport-safe sizing. An empty eligible pace set uses the existing empty state, never a non-pace fallback. Classification badges do not replace validity or experiment exclusion badges. +Pending/running copy says historical pace may change. Finished work with only limited/unclassified outcomes uses neutral completion copy and no action-needed warning; only actual execution failures request attention. Keep classified, limited, failed, and pending counts separate. Terminal dock can be dismissed locally without clearing authoritative state; inline details and retry remain available. Use Paraglide in every supported locale, accessible status/alert text, keyboard controls, and viewport-safe sizing. An empty eligible pace set uses the existing empty state, never a non-pace fallback. Classification badges do not replace validity or experiment exclusion badges. ## Acceptance criteria and E2E evidence map @@ -105,9 +105,9 @@ Each row is required. Scenario definitions below include setup, action, and obse | AC-04 | Explicit inspection, API metadata, intentional comparison, export/import, and reprocessing retain category and telemetry, including old exports. | E2E-01, E2E-07 | | AC-05 | Normal startup automatically discovers and processes historical native recordings once per version with no confirmation; identity and unrelated metadata/bytes remain unchanged. MoTeC imports remain unchanged and outside this inventory. | E2E-01, E2E-03, E2E-05, F12 | | AC-06 | Crash/restart is recording-atomic; committed dirty aggregates recover without replaying completed classification or serving stale results as current. | E2E-03 | -| AC-07 | Ambiguous/missing/corrupt/unsupported sources preserve originals, expose specific limitations, continue other work, and do not spin on unchanged input. | E2E-04 | +| AC-07 | Ambiguous matches and absent classifications are limitations, not failed runs. Corrupt input and execution errors remain failed. All preserve originals and continue other work without unchanged-input retry loops. Previously persisted ambiguous failures become limited without replay or metadata changes. | E2E-04 | | AC-08 | Active capture, shared-file session boundaries, and concurrent processing cannot produce stale overwrites; work remains bounded and live capture/startup remain usable. | E2E-05 | -| AC-09 | Real app restores status after restart/reconnect, exposes translated progress/limitations/retry, refreshes pace caches, and remains accessible at narrow/wide sizes. | E2E-03, E2E-06, E2E-07 | +| AC-09 | Real app restores status after restart/reconnect, shows neutral completion for limited-only outcomes and attention only for execution failures, exposes translated details/retry, refreshes pace caches, and remains accessible at narrow/wide sizes. | E2E-03, E2E-06, E2E-07 | ### Shared E2E execution strategy @@ -122,9 +122,9 @@ Extend the upgrade subprocess harness to report an ephemeral HTTP address and al | E2E-01: normal upgrade and parity | Pre-feature DB with pace, out/in/pit, grid (#222), caution, category-only invalid, genuine invalid, and overlapping-category captures → normal boot without start/reprocess request → categories/quality equal fresh processing of same source; faster non-pace never wins; inspect each by ID; invariant snapshot unchanged; second boot performs no completed decode. | | E2E-02: evidence boundaries | Native fixtures for practice/qualifying Lap 1, first capture mid-race, rolling start, unknown/missing session, restart/rewind/parser reset, first next-lap flag, transient yellow then green, SC/VSC, stale/default flags, first pit exit/final entry, Forza provisional correction, and legitimate versus malformed open traces → replay through actual acquisition/replay entry points, portable batch and compact/selected processing → only positively evidenced categories, correct completed-lap ownership and priority, quality retained; selected processing matches full pass with bounded context priming. Exercise each supported game source; document unsupported evidence as limitation. | | E2E-03: crash and refresh | Seed old bests and open dashboard in browser; gate before/inside commit and immediately after commit before rebuild → kill/restart at each point → precommit changes/markers roll back together; unfinished work resumes; postcommit pass is skipped but dirty aggregate rebuild drains, old best never presented as current, reconnect invalidates browser queries. Observe persisted state plus HTTP/UI results, not only log markers. | -| E2E-04: limitations and repair | Include duplicate lap numbers across sessions/restarts, mismatched indexes, missing and corrupt sources, absent native signals, and one good recording → boot → ambiguity/corruption fails atomically, missing/evidence is limited, good recording completes, legacy metadata/validity remain reliable, no unchanged-input retry loop; repair file and click real Retry → one durable job, current metadata or explicit remaining limitation; duplicate clicks/restart do not duplicate work. | +| E2E-04: limitations and repair | Include duplicate lap numbers across sessions/restarts, mismatched indexes, missing and corrupt sources, absent native signals, and one good recording → boot → ambiguity/missing evidence is limited, corruption fails atomically, good recording completes, and original lap metadata remains unchanged. Seed an old failed/ambiguous outcome → restart → limited with unchanged attempts and metadata and a newer status revision. Repair a source and click real Retry → one durable job with current metadata or an explicit remaining limitation; duplicate clicks/restart do not duplicate work or spin on unchanged input. | | E2E-05: concurrency and load | Multiple sessions share source; finalized backlog plus active capture; pause worker mid-decode → finalize capture and run real import/manual reprocessing/source replacement while worker runs → single ownership, source/result revision mismatch discards stale computation, finalized pending input eventually runs, one source parse per attempt not per lap. At frame-batch barrier, health/status request and live frame ingestion/completion succeed before releasing backfill; assert one worker and bounded buffering independently of library size. | -| E2E-06: status and recovery UI | Browser attaches to real upgrade server with gated work and unresolved records → navigate, reconnect, restart, refresh, page unresolved list, retry, suppress/dismiss dock → counters follow contract, stale revision ignored, cursor revision mismatch restarts listing, status survives browser loss; inline details remain when suppressed/dismissed; no start confirmation. Verify existing dock priorities, keyboard focus, status/alert labels, 390px/1440px layouts and a long-copy locale. Exercise real request failure/recovery by owned server stop/restart, without response mocks. | +| E2E-06: status and recovery UI | Browser attaches to a real upgrade server with gated work and unresolved records → navigate, reconnect, restart, refresh, page unresolved list, retry, suppress/dismiss dock → limited-only work finishes neutrally with `complete` status and no attention warning; actual execution failures retain attention. Counters follow contract; stale revisions are ignored, stale cursors restart listing, status survives browser loss, and inline details remain when suppressed/dismissed. Verify dock priorities, keyboard focus, status/alert labels, 390px/1440px layouts and a long-copy locale. Exercise real request failure/recovery by owned server stop/restart without response mocks. | | E2E-07: consumers and transfer | Upgraded DB with a faster classified lap, slower pace lap, invalid lap, manual experiment exclusion, and non-pace-only combo → inspect actual session/analyse/compare/dashboard/review/strategy paths → API/default/SQL bests and sectors select only eligible candidates, inventory unchanged, explicit selection warns but works, empty combo has no fallback. Export/import compact/full and legacy export then reprocess through production entry points → telemetry/category preserved or recomputed with context, legacy-null visibly limited/pending rather than silently complete; verify narrow/wide badges and accessible labels. | Implementation must run all scenarios and record command, fixture/baseline, outcome, and evidence location in the PR or repository's existing evidence location. Required commands include `bun test --timeout 600000 apps/backend/test/e2e/database-upgrade.test.ts apps/backend/test/e2e/classification-migration.test.ts`; `cd playwright && bunx tsc --project tsconfig.json --noEmit`; targeted `E2E_SERVER_MODE=dev PW_SERVER_SET=seeded bunx playwright test --project=seeded-e2e tests/seeded/sessions/classification-migration.spec.ts` from `playwright/`; and root `bun run typecheck`. Run relevant existing consumer suites as supplements. A missing capture, release baseline, browser, dependency, or unimplemented scenario is a named blocker, never passing evidence. Record actual integration outcomes after execution rather than treating implementation as proof. @@ -168,3 +168,7 @@ The [correction execution record](../../.omp/evidence/pr469-review-fixes/results This correction-specific evidence does not close the pre-existing broader feature gaps listed above, including the absence of positive native start evidence in the real #222 capture. The [additional review-fix verification](../../.omp/evidence/review-fixes-20261019/results.json) records 19 passing real-upgrade scenarios, 49 native/live regressions, 38 database/result/changelog regressions, root typecheck/lint, Playwright typecheck, and the production client build. Production UI smoke against the isolated real backend verified empty default analysis at 390px/1440px and explicit non-pace inspection. The dev-mode maintenance Playwright run timed out during initial navigation before reaching the changed UI; it is not recorded as passing. These correction-specific results do not close the broader feature gaps above. + +## Classification outcome correction verification + +The 2026-10-21 changed-scope verification covers the corrected AC-07/AC-09 semantics: 40 real backend E2E scenarios passed, including persisted ambiguous-result repair without replay or lap changes and retained execution failures. Typecheck, changed-path lint, and 11 changelog checks passed. The live application returned `complete` with one classified, eight limited, and zero failed recordings; desktop/mobile browser inspection showed neutral completion and no attention warning. The amended full Playwright upgrade scenario was not executed; this evidence does not claim closure of unrelated feature acceptance criteria. diff --git a/docs/plans/valid-non-pace-laps-ui-plan.md b/docs/plans/valid-non-pace-laps-ui-plan.md index cd4b171fb..580f62cab 100644 --- a/docs/plans/valid-non-pace-laps-ui-plan.md +++ b/docs/plans/valid-non-pace-laps-ui-plan.md @@ -31,7 +31,9 @@ All shared acceptance definitions and setup/action/assertion scenarios remain in | UI-03: eligible defaults, retained history, category/validity separation | AC-01/03/04 | E2E-01/07 | | UI-04: actual narrow/wide translated and accessible presentation | AC-09 | E2E-06/07 | -Zero inventory hides activity (`idle`); limited/failed-only inventory shows attention, not success (E2E-06). Suppressed or dismissed dock leaves inline details usable; existing layout/sparse priority remains intact (E2E-06). Failed request preserves state and permits restoration; reconnect ignores stale revisions and refetches queries rather than depending on missed events (E2E-03/06). Pagination revision mismatch restarts listing, duplicate Retry cannot enqueue duplicate work, unrepaired sources remain visibly limited (E2E-04/06). Legacy-null may remain pace-compatible while pending/limited but never implies successful migration; non-pace-only defaults show empty states; explicitly selected classified laps still work (E2E-07). Long translated copy fits 390px and 1440px widths, status is conveyed without color alone, and background work does not trap focus (E2E-06/07). +Zero inventory hides activity (`idle`); limited-only inventory shows neutral completion (`complete`), while execution failures show attention (AC-09, E2E-06). Completion copy describes finished processing without claiming every lap was classified; keep limited counters and inline reasons available. Suppressed or dismissed dock leaves inline details usable; existing layout/sparse priority remains intact (E2E-06). Failed request preserves state and permits restoration; reconnect ignores stale revisions and refetches queries rather than depending on missed events (E2E-03/06). Pagination revision mismatch restarts listing, duplicate Retry cannot enqueue duplicate work, and unrepaired sources remain visibly limited without an action-needed warning (E2E-04/06). Legacy-null may remain pace-compatible while pending/limited and never implies a known lap category; non-pace-only defaults show empty states and explicitly selected classified laps still work (E2E-07). Long translated copy fits 390px and 1440px widths, status is conveyed without color alone, and background work does not trap focus (E2E-06/07). +For AC-07 and E2E-04, follow the shared outcome semantics: inconclusive matching and unclassified laps contribute to limitations, never execution-failure counts. Persisted ambiguous outcomes may move from failed to limited on refresh/reconnect without another processing attempt; preserve their diagnostic reason and any existing classification. + ## Verification and completion dependency diff --git a/playwright/tests/seeded/sessions/classification-migration.spec.ts b/playwright/tests/seeded/sessions/classification-migration.spec.ts index a4ef8eb7d..f313ba808 100644 --- a/playwright/tests/seeded/sessions/classification-migration.spec.ts +++ b/playwright/tests/seeded/sessions/classification-migration.spec.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; const ROOT = resolve(__dirname, "../../../.."); -test("real upgrade restores attention, preserves it offline, and repairs through feature Retry", async ({ page }, testInfo) => { +test("real upgrade restores limited results, preserves them offline, and repairs through feature Retry", async ({ page }, testInfo) => { test.setTimeout(720_000); const process: ChildProcessWithoutNullStreams = spawn("bun", ["client/test/support/upgrade-browser-server.ts"], { cwd: ROOT, env: { ...globalThis.process.env }, stdio: "pipe", @@ -75,7 +75,7 @@ test("real upgrade restores attention, preserves it offline, and repairs through await expect(dock).toBeHidden(); await page.getByRole("dialog", { name: "Sparse recordings split" }).getByRole("button", { name: "Close", exact: true }).click(); await expect(page.getByRole("dialog", { name: "Sparse recordings split" })).toBeHidden(); - await expect(dock).toContainText("Some recordings need attention"); + await expect(dock).toBeVisible(); await dock.getByRole("button", { name: "Close", exact: true }).click(); await expect(dock).toBeHidden(); @@ -149,8 +149,7 @@ test("real upgrade restores attention, preserves it offline, and repairs through const retry = maintenance.getByRole("button", { name: /^Retry / }).first(); await retry.focus(); await page.keyboard.press("Enter"); - await expect(maintenance).toContainText("Historical recordings classified.", { timeout: 180_000 }); - await expect(maintenance.getByRole("button", { name: /^Retry / })).toHaveCount(0); + await expect(maintenance.getByRole("button", { name: /^Retry / })).toHaveCount(0, { timeout: 180_000 }); await expect.poll(() => trackLapRequests).toBeGreaterThan(beforeCommitRefresh); const beforeReconnectRefresh = trackLapRequests; await control("stop-backend"); @@ -162,7 +161,7 @@ test("real upgrade restores attention, preserves it offline, and repairs through await expect(layout).toBeVisible(); await expect(page.getByRole("dialog", { name: "Historical lap classification" })).toBeHidden(); await layout.getByRole("button", { name: "Later", exact: true }).click(); - await expect(page.getByRole("dialog", { name: "Historical lap classification" })).toContainText("Historical recordings classified."); + await expect(page.getByRole("dialog", { name: "Historical lap classification" })).toBeVisible(); } finally { process.stdin.write("quit\n"); process.stdin.end(); diff --git a/server/session-capture/classification-migration-worker.ts b/server/session-capture/classification-migration-worker.ts index 1e3a2f24a..79902e679 100644 --- a/server/session-capture/classification-migration-worker.ts +++ b/server/session-capture/classification-migration-worker.ts @@ -83,7 +83,7 @@ async function processClaimedRecording(recordingId: string, sourceKey: string, s lte(sessions.id, migrationMeta?.cutoffSessionId ?? 0), )).orderBy(sessions.id).all(); if (!existing.length) return finish(recordingId, sourceRevision, "failed", "source_changed", sourcePath, sourceStat); - if (new Set(existing.map((session) => session.gameId)).size !== 1) return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + if (new Set(existing.map((session) => session.gameId)).size !== 1) return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); const gameId = existing[0]!.gameId as GameId; let game; try { game = getServerGame(gameId); } catch { return finish(recordingId, sourceRevision, "limited", "insufficient_evidence", sourcePath, sourceStat); } @@ -174,25 +174,25 @@ async function processClaimedRecording(recordingId: string, sourceKey: string, s }[] = []; for (const detectedLap of detected) { const capturedSession = capture.sessions[detectedLap.sessionId - 1]; - if (!capturedSession) return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + if (!capturedSession) return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); const matches = existingLaps.filter((lap) => !used.has(lap.id) && lap.rawByteOffset != null && lap.rawByteOffset === detectedLap.rawByteOffset && lap.rawFrameCount === detectedLap.rawFrameCount ); - if (matches.length !== 1) return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + if (matches.length !== 1) return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); const matchedLap = matches[0]!; const owner = existing.find((session) => session.id === matchedLap.sessionId); - if (!owner) return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + if (!owner) return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); const mappedOwner = capturedToStoredSession.get(detectedLap.sessionId); if (mappedOwner !== undefined && mappedOwner !== owner.id) { - return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); } const hasIdentity = (value: string | null) => value !== null && value !== "" && value !== "0" && value !== "unknown"; if ((hasIdentity(capturedSession.carId) && hasIdentity(owner.carId) && capturedSession.carId !== owner.carId) || (hasIdentity(capturedSession.trackId) && hasIdentity(owner.trackId) && capturedSession.trackId !== owner.trackId)) { - return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); } capturedToStoredSession.set(detectedLap.sessionId, owner.id); used.add(matchedLap.id); @@ -214,7 +214,7 @@ async function processClaimedRecording(recordingId: string, sourceKey: string, s }); } } - if (used.size !== existingLaps.length) return finish(recordingId, sourceRevision, "failed", "ambiguous_match", sourcePath, sourceStat); + if (used.size !== existingLaps.length) return finish(recordingId, sourceRevision, "limited", "ambiguous_match", sourcePath, sourceStat); if (detected.some((lap) => !hasKnownClassificationOutcome(lap))) { return finish(recordingId, sourceRevision, "limited", "insufficient_evidence", sourcePath, sourceStat); } diff --git a/server/session-capture/classification-migration.ts b/server/session-capture/classification-migration.ts index 4c89d246a..69c1c8776 100644 --- a/server/session-capture/classification-migration.ts +++ b/server/session-capture/classification-migration.ts @@ -157,6 +157,11 @@ export async function reconcileClassificationMigrationInventory(options: { reset const reset = await initialization.execute("UPDATE classification_migration_recordings SET state='pending',reason=NULL WHERE state='running'"); changed ||= Number(reset.rowsAffected) > 0; } + const limitations = await initialization.execute({ + sql: "UPDATE classification_migration_recordings SET state='limited' WHERE version=? AND state='failed' AND reason='ambiguous_match'", + args: [CLASSIFICATION_MIGRATION_VERSION], + }); + changed ||= Number(limitations.rowsAffected) > 0; if (changed) await initialization.execute("UPDATE classification_migration_meta SET revision=revision+1 WHERE id=1"); }); @@ -237,7 +242,7 @@ function migrationStatusFrom( const limited = values.get("limited") ?? 0; const failed = values.get("failed") ?? 0; const total = pending + running + completed + limited + failed; - const status = total === 0 ? "idle" : pending || running || aggregatesPending ? "running" : limited || failed ? "attention" : "complete"; + const status = total === 0 ? "idle" : pending || running || aggregatesPending ? "running" : failed ? "attention" : "complete"; return { version: CLASSIFICATION_MIGRATION_VERSION, revision, status, total, pending, running, completed, limited, failed, aggregatesPending }; } From d746c2cc92a6ca3ea5414e328940e2531ce017b6 Mon Sep 17 00:00:00 2001 From: Snazzie Date: Wed, 21 Oct 2026 15:43:02 +0100 Subject: [PATCH 2/2] fix(ci): ignore ANSI codes in lifecycle test --- CHANGELOG.md | 1 + scripts/test/tests/paraglide-lifecycle.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 274b0e6c4..0d837afd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ - Skip LMU outline requests when stored track names do not identify one layout. ### Internal +- Ignore terminal color codes when waiting for compiler startup in the Paraglide lifecycle CI test. - Generate Paraglide declarations with the installed native TypeScript compiler and stop abandoned translation watchers from consuming CPU after development exits. - Use Ubuntu instead of macOS for CI runners and cap Mars memory requests at 6 GB. - Invalidate cached frontend builds when source changes in dependent workspaces. diff --git a/scripts/test/tests/paraglide-lifecycle.test.ts b/scripts/test/tests/paraglide-lifecycle.test.ts index 1eef6d3bc..32bc8832f 100644 --- a/scripts/test/tests/paraglide-lifecycle.test.ts +++ b/scripts/test/tests/paraglide-lifecycle.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { copyFile, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { stripVTControlCharacters } from "node:util"; const root = resolve(import.meta.dir, "../../.."); @@ -61,7 +62,7 @@ setInterval(() => {}, 1000); if (chunk.done) throw new Error(`Parent exited before watcher readiness: ${output}`); output += new TextDecoder().decode(chunk.value); } - orphanPid = Number(output.match(/Watcher PID: (\d+)/)?.[1]); + orphanPid = Number(stripVTControlCharacters(output).match(/Watcher PID: (\d+)/)?.[1]); expect(orphanPid).toBeGreaterThan(0); parent.kill("SIGKILL"); await parent.exited; @@ -78,7 +79,7 @@ setInterval(() => {}, 1000); children.push(compiling); const compilingReader = compiling.stdout.getReader(); let compilingOutput = ""; - while (!compilingOutput.includes("[paraglide-js] Compiling")) { + while (!stripVTControlCharacters(compilingOutput).includes("[paraglide-js] Compiling")) { const chunk = await compilingReader.read(); if (chunk.done) throw new Error(`Compiler exited before startup: ${compilingOutput}`); compilingOutput += new TextDecoder().decode(chunk.value);