diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b4edfe6..0d837afd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,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. - Center Le Mans Ultimate track outlines within catalogue cards. - Select and deselect child laps with the Sessions table header checkbox, preserving selections on other pages and ownership tabs. - Stop recording curb maps and using detected curbs to recalibrate track boundaries; preserve telemetry-based curb detection. @@ -79,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/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/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); 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 }; }