From fc40a8c79ca6fbfa97bf9f137cd40bd4d1ecb2f9 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:09:58 +0900 Subject: [PATCH 1/3] Define session status model --- apps/desktop/main.cjs | 76 ++++++++++++++++++- apps/desktop/main/native-launch.node-test.cjs | 46 +++++++++++ apps/desktop/src/types.ts | 10 +++ 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index bee8d89..f77cc04 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -1358,6 +1358,58 @@ function listenerSessionFromMetadata(metadata, noteId) { }; } +function normalizeAppSessionStatus(input = {}) { + const listenerSession = input.listenerSession && typeof input.listenerSession === "object" + ? input.listenerSession + : null; + const sttChunks = Array.isArray(input.sttChunks) ? input.sttChunks : []; + const latestChunks = sttChunks.some((chunk) => optionalNonEmptyString(chunk?.chunkID)) + ? latestSTTChunks(sttChunks) + : sttChunks; + const runningChunkCount = latestChunks.filter((chunk) => chunk.status === "queued" || chunk.status === "running").length; + const failedChunkCount = latestChunks.filter((chunk) => chunk.status === "failed").length; + const retryableChunkCount = latestChunks.filter((chunk) => isRetryableSTTChunkStatus(chunk.status)).length; + const base = { + detail: null, + retryableChunkCount, + failedChunkCount, + runningChunkCount, + }; + const listenerState = normalizeListenerSessionState(listenerSession?.state); + const listenerDetail = optionalNonEmptyString(listenerSession?.error) || optionalNonEmptyString(listenerSession?.detail); + + if (listenerState === "failed") { + return { ...base, state: "failed", detail: listenerDetail }; + } + if (listenerState === "recovering") { + return { ...base, state: "recoverable", detail: listenerDetail || "Session needs recovery." }; + } + if (listenerState === "recording") { + return { + ...base, + state: listenerDetail ? "degraded" : "recording", + detail: listenerDetail, + }; + } + if (listenerState === "starting" || listenerState === "stopping") { + return { ...base, state: "finalizing", detail: listenerDetail }; + } + if (runningChunkCount > 0) { + return { ...base, state: "transcribing", detail: "Transcription is running." }; + } + if (failedChunkCount > 0) { + return { + ...base, + state: "recoverable", + detail: `${failedChunkCount} STT ${failedChunkCount === 1 ? "chunk needs" : "chunks need"} retry.`, + }; + } + if (listenerState === "completed" || latestChunks.some((chunk) => chunk.status === "completed")) { + return { ...base, state: "completed", detail: listenerDetail }; + } + return null; +} + async function writeListenerSessionState(noteId, patch) { if (!noteId) { return; @@ -1798,6 +1850,9 @@ async function loadBundleSnapshot(directoryName) { ? metadata.transcriptSegmentCount : transcriptSegments.length; + const listenerSession = listenerSessionFromMetadata(metadata, directoryName); + const sttChunks = readLatestSTTChunksForDirectorySync(directoryPath); + const summary = { id: directoryName, title: displayTitleFromBundle(directoryName, metadata, markdown), @@ -1814,7 +1869,8 @@ async function loadBundleSnapshot(directoryName) { transcriptPath: paths.transcriptPath, metadataPath: paths.metadataPath, recordingPath: paths.recordingPath, - listenerSession: listenerSessionFromMetadata(metadata, directoryName), + listenerSession, + sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }), }; return { @@ -1828,6 +1884,8 @@ async function loadBundleSnapshot(directoryName) { function noteSummaryFromRow(row) { const metadata = parseMetadataJSON(row.metadata_json); + const listenerSession = listenerSessionFromMetadata(metadata, row.id); + const sttChunks = readLatestSTTChunksForDirectorySync(row.directory_path); return { id: row.id, title: row.title, @@ -1844,7 +1902,8 @@ function noteSummaryFromRow(row) { transcriptPath: row.transcript_path, metadataPath: row.metadata_path, recordingPath: row.recording_path, - listenerSession: listenerSessionFromMetadata(metadata, row.id), + listenerSession, + sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }), }; } @@ -2431,6 +2490,7 @@ async function readNote(id) { }); const directoryPath = resolveBundleDirectory(row.id); const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME)))); + const summary = noteSummaryFromRow(row); const speakerLabels = database.prepare(` SELECT speaker_id, @@ -2449,7 +2509,8 @@ async function readNote(id) { markNoteOpened(id); return { - ...noteSummaryFromRow(row), + ...summary, + sessionStatus: normalizeAppSessionStatus({ listenerSession: summary.listenerSession, sttChunks }), directoryPath, markdown: row.markdown, metadata: parseMetadataJSON(row.metadata_json), @@ -2693,6 +2754,14 @@ function parseSTTChunkLedgerJSONL(raw) { return records; } +function readLatestSTTChunksForDirectorySync(directoryPath) { + try { + return latestSTTChunks(parseSTTChunkLedgerJSONL(fsSync.readFileSync(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME), "utf8"))); + } catch { + return []; + } +} + function latestSTTChunks(records) { const latestByChunk = new Map(); for (const record of records) { @@ -4538,6 +4607,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { mergeSummarySettingsPatch, normalizeCustomSummaryTemplate, normalizeCustomSummaryTemplates, + normalizeAppSessionStatus, normalizeListenerSessionState, normalizeSummarySettings, nativeHelperWorkingDirectory, diff --git a/apps/desktop/main/native-launch.node-test.cjs b/apps/desktop/main/native-launch.node-test.cjs index 88cff35..14f8076 100644 --- a/apps/desktop/main/native-launch.node-test.cjs +++ b/apps/desktop/main/native-launch.node-test.cjs @@ -7,6 +7,7 @@ const { latestSTTChunks, latestRetryableSTTChunks, nativeHelperWorkingDirectory, + normalizeAppSessionStatus, normalizeListenerSessionState, normalizeTranscriptSegments, parseSTTChunkLedgerJSONL, @@ -20,6 +21,51 @@ assert.equal(normalizeListenerSessionState("finalizing"), "stopping"); assert.equal(normalizeListenerSessionState("completed"), "completed"); assert.equal(normalizeListenerSessionState("unknown"), null); +assert.deepEqual(normalizeAppSessionStatus({ + listenerSession: { state: "recording", error: null, detail: null }, + sttChunks: [], +}), { + state: "recording", + detail: null, + retryableChunkCount: 0, + failedChunkCount: 0, + runningChunkCount: 0, +}); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "recording", error: "System audio unavailable.", detail: null }, + sttChunks: [], +}).state, "degraded"); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "stopping", error: null, detail: null }, + sttChunks: [], +}).state, "finalizing"); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "failed", error: "Capture failed.", detail: null }, + sttChunks: [], +}).state, "failed"); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "recovering", error: null, detail: null }, + sttChunks: [], +}).state, "recoverable"); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "completed", error: null, detail: null }, + sttChunks: [{ status: "running" }, { status: "completed" }], +}).state, "transcribing"); +assert.deepEqual(normalizeAppSessionStatus({ + listenerSession: { state: "completed", error: null, detail: null }, + sttChunks: [{ status: "failed" }, { status: "completed" }], +}), { + state: "recoverable", + detail: "1 STT chunk needs retry.", + retryableChunkCount: 1, + failedChunkCount: 1, + runningChunkCount: 0, +}); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "completed", error: null, detail: null }, + sttChunks: [{ status: "completed" }], +}).state, "completed"); + const runtimeSegment = { id: "seg-1", sessionID: "session-a", diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 0ad0040..2bec12a 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -8,6 +8,7 @@ export type EditorCommand = "paragraph" | "heading1" | "heading2" | "bullet" | " export type NoteActivity = "recording" | "finalizing" | "failed" | null; export type TranscriptSource = "microphone" | "system"; export type ListenerSessionState = "starting" | "recording" | "stopping" | "recovering" | "failed" | "completed"; +export type AppSessionState = "recording" | "degraded" | "finalizing" | "transcribing" | "failed" | "completed" | "recoverable"; export type STTChunkStatus = "queued" | "running" | "completed" | "failed"; export type SpeakerLabelingMode = "local" | "disabled"; export type AppLanguage = "en" | "ko"; @@ -80,6 +81,7 @@ export interface NoteSummary { metadataPath: string; recordingPath: string; listenerSession: ListenerSession | null; + sessionStatus: AppSessionStatus | null; } export interface TranscriptSegment { @@ -106,6 +108,14 @@ export interface ListenerSession { error: string | null; } +export interface AppSessionStatus { + state: AppSessionState; + detail: string | null; + retryableChunkCount: number; + failedChunkCount: number; + runningChunkCount: number; +} + export interface STTChunk { sessionID: string | null; chunkID: string; From bfbf81501235bf6c45ee0cd03a8c6d827752e835 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:21:18 +0900 Subject: [PATCH 2/3] Classify starting sessions as recording --- apps/desktop/main.cjs | 9 +++++---- apps/desktop/main/native-launch.node-test.cjs | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index f77cc04..e195cd3 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -1376,7 +1376,8 @@ function normalizeAppSessionStatus(input = {}) { runningChunkCount, }; const listenerState = normalizeListenerSessionState(listenerSession?.state); - const listenerDetail = optionalNonEmptyString(listenerSession?.error) || optionalNonEmptyString(listenerSession?.detail); + const listenerError = optionalNonEmptyString(listenerSession?.error); + const listenerDetail = listenerError || optionalNonEmptyString(listenerSession?.detail); if (listenerState === "failed") { return { ...base, state: "failed", detail: listenerDetail }; @@ -1384,14 +1385,14 @@ function normalizeAppSessionStatus(input = {}) { if (listenerState === "recovering") { return { ...base, state: "recoverable", detail: listenerDetail || "Session needs recovery." }; } - if (listenerState === "recording") { + if (listenerState === "starting" || listenerState === "recording") { return { ...base, - state: listenerDetail ? "degraded" : "recording", + state: listenerError ? "degraded" : "recording", detail: listenerDetail, }; } - if (listenerState === "starting" || listenerState === "stopping") { + if (listenerState === "stopping") { return { ...base, state: "finalizing", detail: listenerDetail }; } if (runningChunkCount > 0) { diff --git a/apps/desktop/main/native-launch.node-test.cjs b/apps/desktop/main/native-launch.node-test.cjs index 14f8076..9546d47 100644 --- a/apps/desktop/main/native-launch.node-test.cjs +++ b/apps/desktop/main/native-launch.node-test.cjs @@ -35,6 +35,10 @@ assert.equal(normalizeAppSessionStatus({ listenerSession: { state: "recording", error: "System audio unavailable.", detail: null }, sttChunks: [], }).state, "degraded"); +assert.equal(normalizeAppSessionStatus({ + listenerSession: { state: "starting", error: null, detail: "Preparing local capture." }, + sttChunks: [], +}).state, "recording"); assert.equal(normalizeAppSessionStatus({ listenerSession: { state: "stopping", error: null, detail: null }, sttChunks: [], From 6e6a15bb9842f94e445ecc9fc10f680cd9bda8b8 Mon Sep 17 00:00:00 2001 From: qyinm Date: Mon, 4 May 2026 12:23:14 +0900 Subject: [PATCH 3/3] Avoid sync ledger reads in note summaries --- apps/desktop/main.cjs | 46 +++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index e195cd3..22373e4 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -32,7 +32,7 @@ const { const APP_DISPLAY_NAME = "MirrorNote"; const ELECTRON_SETTINGS_FILE_NAME = "electron-settings.json"; const NOTES_DATABASE_FILE_NAME = "notes.sqlite"; -const NOTES_DATABASE_SCHEMA_VERSION = 3; +const NOTES_DATABASE_SCHEMA_VERSION = 4; const MENU_BAR_ICON_SIZE = 18; const MENU_BAR_ICON_IDLE_FILE_NAME = "menu-bar-icon-source.png"; const MENU_BAR_ICON_RECORDING_BRIGHT_FILE_NAME = "menu-bar-icon-recording-bright.png"; @@ -1411,6 +1411,24 @@ function normalizeAppSessionStatus(input = {}) { return null; } +function normalizeStoredAppSessionStatus(value) { + const raw = typeof value === "string" ? parseMetadataJSON(value) : value; + if (!raw || typeof raw !== "object") { + return null; + } + const state = optionalNonEmptyString(raw.state); + if (!["recording", "degraded", "finalizing", "transcribing", "failed", "completed", "recoverable"].includes(state)) { + return null; + } + return { + state, + detail: optionalNonEmptyString(raw.detail), + retryableChunkCount: Number.isFinite(Number(raw.retryableChunkCount)) ? Number(raw.retryableChunkCount) : 0, + failedChunkCount: Number.isFinite(Number(raw.failedChunkCount)) ? Number(raw.failedChunkCount) : 0, + runningChunkCount: Number.isFinite(Number(raw.runningChunkCount)) ? Number(raw.runningChunkCount) : 0, + }; +} + async function writeListenerSessionState(noteId, patch) { if (!noteId) { return; @@ -1688,6 +1706,14 @@ function applyNotesDatabaseMigrations(database) { `); }, }, + { + version: 4, + up: (db) => { + db.exec(` + ALTER TABLE notes ADD COLUMN session_status_json TEXT; + `); + }, + }, ]; for (const migration of migrations) { @@ -1852,7 +1878,7 @@ async function loadBundleSnapshot(directoryName) { : transcriptSegments.length; const listenerSession = listenerSessionFromMetadata(metadata, directoryName); - const sttChunks = readLatestSTTChunksForDirectorySync(directoryPath); + const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME)))); const summary = { id: directoryName, @@ -1886,7 +1912,6 @@ async function loadBundleSnapshot(directoryName) { function noteSummaryFromRow(row) { const metadata = parseMetadataJSON(row.metadata_json); const listenerSession = listenerSessionFromMetadata(metadata, row.id); - const sttChunks = readLatestSTTChunksForDirectorySync(row.directory_path); return { id: row.id, title: row.title, @@ -1904,7 +1929,7 @@ function noteSummaryFromRow(row) { metadataPath: row.metadata_path, recordingPath: row.recording_path, listenerSession, - sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }), + sessionStatus: normalizeStoredAppSessionStatus(row.session_status_json), }; } @@ -1944,9 +1969,10 @@ function upsertNoteSnapshot(snapshot) { recording_path, markdown, metadata_json, + session_status_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, started_at = excluded.started_at, @@ -1962,6 +1988,7 @@ function upsertNoteSnapshot(snapshot) { recording_path = excluded.recording_path, markdown = excluded.markdown, metadata_json = excluded.metadata_json, + session_status_json = excluded.session_status_json, updated_at = excluded.updated_at `).run( summary.id, @@ -1979,6 +2006,7 @@ function upsertNoteSnapshot(snapshot) { summary.recordingPath, markdown, serializeMetadata(metadata), + serializeMetadata(summary.sessionStatus), createdAt, now, ); @@ -2755,14 +2783,6 @@ function parseSTTChunkLedgerJSONL(raw) { return records; } -function readLatestSTTChunksForDirectorySync(directoryPath) { - try { - return latestSTTChunks(parseSTTChunkLedgerJSONL(fsSync.readFileSync(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME), "utf8"))); - } catch { - return []; - } -} - function latestSTTChunks(records) { const latestByChunk = new Map(); for (const record of records) {