Skip to content

Commit 5545623

Browse files
authored
Define session status model (#14)
Define a product-level session status model for notes. - Normalize listener metadata and STT chunk status into sessionStatus - Store aggregate session status in the notes database for list views - Treat starting sessions as recording rather than finalizing - Avoid sync STT ledger reads while building note summaries Validation: - npm run desktop:check - git diff --check HEAD~2..HEAD
1 parent 0c7905b commit 5545623

3 files changed

Lines changed: 156 additions & 5 deletions

File tree

apps/desktop/main.cjs

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const {
3232
const APP_DISPLAY_NAME = "MirrorNote";
3333
const ELECTRON_SETTINGS_FILE_NAME = "electron-settings.json";
3434
const NOTES_DATABASE_FILE_NAME = "notes.sqlite";
35-
const NOTES_DATABASE_SCHEMA_VERSION = 3;
35+
const NOTES_DATABASE_SCHEMA_VERSION = 4;
3636
const MENU_BAR_ICON_SIZE = 18;
3737
const MENU_BAR_ICON_IDLE_FILE_NAME = "menu-bar-icon-source.png";
3838
const MENU_BAR_ICON_RECORDING_BRIGHT_FILE_NAME = "menu-bar-icon-recording-bright.png";
@@ -1358,6 +1358,77 @@ function listenerSessionFromMetadata(metadata, noteId) {
13581358
};
13591359
}
13601360

1361+
function normalizeAppSessionStatus(input = {}) {
1362+
const listenerSession = input.listenerSession && typeof input.listenerSession === "object"
1363+
? input.listenerSession
1364+
: null;
1365+
const sttChunks = Array.isArray(input.sttChunks) ? input.sttChunks : [];
1366+
const latestChunks = sttChunks.some((chunk) => optionalNonEmptyString(chunk?.chunkID))
1367+
? latestSTTChunks(sttChunks)
1368+
: sttChunks;
1369+
const runningChunkCount = latestChunks.filter((chunk) => chunk.status === "queued" || chunk.status === "running").length;
1370+
const failedChunkCount = latestChunks.filter((chunk) => chunk.status === "failed").length;
1371+
const retryableChunkCount = latestChunks.filter((chunk) => isRetryableSTTChunkStatus(chunk.status)).length;
1372+
const base = {
1373+
detail: null,
1374+
retryableChunkCount,
1375+
failedChunkCount,
1376+
runningChunkCount,
1377+
};
1378+
const listenerState = normalizeListenerSessionState(listenerSession?.state);
1379+
const listenerError = optionalNonEmptyString(listenerSession?.error);
1380+
const listenerDetail = listenerError || optionalNonEmptyString(listenerSession?.detail);
1381+
1382+
if (listenerState === "failed") {
1383+
return { ...base, state: "failed", detail: listenerDetail };
1384+
}
1385+
if (listenerState === "recovering") {
1386+
return { ...base, state: "recoverable", detail: listenerDetail || "Session needs recovery." };
1387+
}
1388+
if (listenerState === "starting" || listenerState === "recording") {
1389+
return {
1390+
...base,
1391+
state: listenerError ? "degraded" : "recording",
1392+
detail: listenerDetail,
1393+
};
1394+
}
1395+
if (listenerState === "stopping") {
1396+
return { ...base, state: "finalizing", detail: listenerDetail };
1397+
}
1398+
if (runningChunkCount > 0) {
1399+
return { ...base, state: "transcribing", detail: "Transcription is running." };
1400+
}
1401+
if (failedChunkCount > 0) {
1402+
return {
1403+
...base,
1404+
state: "recoverable",
1405+
detail: `${failedChunkCount} STT ${failedChunkCount === 1 ? "chunk needs" : "chunks need"} retry.`,
1406+
};
1407+
}
1408+
if (listenerState === "completed" || latestChunks.some((chunk) => chunk.status === "completed")) {
1409+
return { ...base, state: "completed", detail: listenerDetail };
1410+
}
1411+
return null;
1412+
}
1413+
1414+
function normalizeStoredAppSessionStatus(value) {
1415+
const raw = typeof value === "string" ? parseMetadataJSON(value) : value;
1416+
if (!raw || typeof raw !== "object") {
1417+
return null;
1418+
}
1419+
const state = optionalNonEmptyString(raw.state);
1420+
if (!["recording", "degraded", "finalizing", "transcribing", "failed", "completed", "recoverable"].includes(state)) {
1421+
return null;
1422+
}
1423+
return {
1424+
state,
1425+
detail: optionalNonEmptyString(raw.detail),
1426+
retryableChunkCount: Number.isFinite(Number(raw.retryableChunkCount)) ? Number(raw.retryableChunkCount) : 0,
1427+
failedChunkCount: Number.isFinite(Number(raw.failedChunkCount)) ? Number(raw.failedChunkCount) : 0,
1428+
runningChunkCount: Number.isFinite(Number(raw.runningChunkCount)) ? Number(raw.runningChunkCount) : 0,
1429+
};
1430+
}
1431+
13611432
async function writeListenerSessionState(noteId, patch) {
13621433
if (!noteId) {
13631434
return;
@@ -1635,6 +1706,14 @@ function applyNotesDatabaseMigrations(database) {
16351706
`);
16361707
},
16371708
},
1709+
{
1710+
version: 4,
1711+
up: (db) => {
1712+
db.exec(`
1713+
ALTER TABLE notes ADD COLUMN session_status_json TEXT;
1714+
`);
1715+
},
1716+
},
16381717
];
16391718

16401719
for (const migration of migrations) {
@@ -1798,6 +1877,9 @@ async function loadBundleSnapshot(directoryName) {
17981877
? metadata.transcriptSegmentCount
17991878
: transcriptSegments.length;
18001879

1880+
const listenerSession = listenerSessionFromMetadata(metadata, directoryName);
1881+
const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME))));
1882+
18011883
const summary = {
18021884
id: directoryName,
18031885
title: displayTitleFromBundle(directoryName, metadata, markdown),
@@ -1814,7 +1896,8 @@ async function loadBundleSnapshot(directoryName) {
18141896
transcriptPath: paths.transcriptPath,
18151897
metadataPath: paths.metadataPath,
18161898
recordingPath: paths.recordingPath,
1817-
listenerSession: listenerSessionFromMetadata(metadata, directoryName),
1899+
listenerSession,
1900+
sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }),
18181901
};
18191902

18201903
return {
@@ -1828,6 +1911,7 @@ async function loadBundleSnapshot(directoryName) {
18281911

18291912
function noteSummaryFromRow(row) {
18301913
const metadata = parseMetadataJSON(row.metadata_json);
1914+
const listenerSession = listenerSessionFromMetadata(metadata, row.id);
18311915
return {
18321916
id: row.id,
18331917
title: row.title,
@@ -1844,7 +1928,8 @@ function noteSummaryFromRow(row) {
18441928
transcriptPath: row.transcript_path,
18451929
metadataPath: row.metadata_path,
18461930
recordingPath: row.recording_path,
1847-
listenerSession: listenerSessionFromMetadata(metadata, row.id),
1931+
listenerSession,
1932+
sessionStatus: normalizeStoredAppSessionStatus(row.session_status_json),
18481933
};
18491934
}
18501935

@@ -1884,9 +1969,10 @@ function upsertNoteSnapshot(snapshot) {
18841969
recording_path,
18851970
markdown,
18861971
metadata_json,
1972+
session_status_json,
18871973
created_at,
18881974
updated_at
1889-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1975+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
18901976
ON CONFLICT(id) DO UPDATE SET
18911977
title = excluded.title,
18921978
started_at = excluded.started_at,
@@ -1902,6 +1988,7 @@ function upsertNoteSnapshot(snapshot) {
19021988
recording_path = excluded.recording_path,
19031989
markdown = excluded.markdown,
19041990
metadata_json = excluded.metadata_json,
1991+
session_status_json = excluded.session_status_json,
19051992
updated_at = excluded.updated_at
19061993
`).run(
19071994
summary.id,
@@ -1919,6 +2006,7 @@ function upsertNoteSnapshot(snapshot) {
19192006
summary.recordingPath,
19202007
markdown,
19212008
serializeMetadata(metadata),
2009+
serializeMetadata(summary.sessionStatus),
19222010
createdAt,
19232011
now,
19242012
);
@@ -2431,6 +2519,7 @@ async function readNote(id) {
24312519
});
24322520
const directoryPath = resolveBundleDirectory(row.id);
24332521
const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME))));
2522+
const summary = noteSummaryFromRow(row);
24342523
const speakerLabels = database.prepare(`
24352524
SELECT
24362525
speaker_id,
@@ -2449,7 +2538,8 @@ async function readNote(id) {
24492538
markNoteOpened(id);
24502539

24512540
return {
2452-
...noteSummaryFromRow(row),
2541+
...summary,
2542+
sessionStatus: normalizeAppSessionStatus({ listenerSession: summary.listenerSession, sttChunks }),
24532543
directoryPath,
24542544
markdown: row.markdown,
24552545
metadata: parseMetadataJSON(row.metadata_json),
@@ -4538,6 +4628,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
45384628
mergeSummarySettingsPatch,
45394629
normalizeCustomSummaryTemplate,
45404630
normalizeCustomSummaryTemplates,
4631+
normalizeAppSessionStatus,
45414632
normalizeListenerSessionState,
45424633
normalizeSummarySettings,
45434634
nativeHelperWorkingDirectory,

apps/desktop/main/native-launch.node-test.cjs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77
latestSTTChunks,
88
latestRetryableSTTChunks,
99
nativeHelperWorkingDirectory,
10+
normalizeAppSessionStatus,
1011
normalizeListenerSessionState,
1112
normalizeTranscriptSegments,
1213
parseSTTChunkLedgerJSONL,
@@ -20,6 +21,55 @@ assert.equal(normalizeListenerSessionState("finalizing"), "stopping");
2021
assert.equal(normalizeListenerSessionState("completed"), "completed");
2122
assert.equal(normalizeListenerSessionState("unknown"), null);
2223

24+
assert.deepEqual(normalizeAppSessionStatus({
25+
listenerSession: { state: "recording", error: null, detail: null },
26+
sttChunks: [],
27+
}), {
28+
state: "recording",
29+
detail: null,
30+
retryableChunkCount: 0,
31+
failedChunkCount: 0,
32+
runningChunkCount: 0,
33+
});
34+
assert.equal(normalizeAppSessionStatus({
35+
listenerSession: { state: "recording", error: "System audio unavailable.", detail: null },
36+
sttChunks: [],
37+
}).state, "degraded");
38+
assert.equal(normalizeAppSessionStatus({
39+
listenerSession: { state: "starting", error: null, detail: "Preparing local capture." },
40+
sttChunks: [],
41+
}).state, "recording");
42+
assert.equal(normalizeAppSessionStatus({
43+
listenerSession: { state: "stopping", error: null, detail: null },
44+
sttChunks: [],
45+
}).state, "finalizing");
46+
assert.equal(normalizeAppSessionStatus({
47+
listenerSession: { state: "failed", error: "Capture failed.", detail: null },
48+
sttChunks: [],
49+
}).state, "failed");
50+
assert.equal(normalizeAppSessionStatus({
51+
listenerSession: { state: "recovering", error: null, detail: null },
52+
sttChunks: [],
53+
}).state, "recoverable");
54+
assert.equal(normalizeAppSessionStatus({
55+
listenerSession: { state: "completed", error: null, detail: null },
56+
sttChunks: [{ status: "running" }, { status: "completed" }],
57+
}).state, "transcribing");
58+
assert.deepEqual(normalizeAppSessionStatus({
59+
listenerSession: { state: "completed", error: null, detail: null },
60+
sttChunks: [{ status: "failed" }, { status: "completed" }],
61+
}), {
62+
state: "recoverable",
63+
detail: "1 STT chunk needs retry.",
64+
retryableChunkCount: 1,
65+
failedChunkCount: 1,
66+
runningChunkCount: 0,
67+
});
68+
assert.equal(normalizeAppSessionStatus({
69+
listenerSession: { state: "completed", error: null, detail: null },
70+
sttChunks: [{ status: "completed" }],
71+
}).state, "completed");
72+
2373
const runtimeSegment = {
2474
id: "seg-1",
2575
sessionID: "session-a",

apps/desktop/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type EditorCommand = "paragraph" | "heading1" | "heading2" | "bullet" | "
88
export type NoteActivity = "recording" | "finalizing" | "failed" | null;
99
export type TranscriptSource = "microphone" | "system";
1010
export type ListenerSessionState = "starting" | "recording" | "stopping" | "recovering" | "failed" | "completed";
11+
export type AppSessionState = "recording" | "degraded" | "finalizing" | "transcribing" | "failed" | "completed" | "recoverable";
1112
export type STTChunkStatus = "queued" | "running" | "completed" | "failed";
1213
export type SpeakerLabelingMode = "local" | "disabled";
1314
export type AppLanguage = "en" | "ko";
@@ -80,6 +81,7 @@ export interface NoteSummary {
8081
metadataPath: string;
8182
recordingPath: string;
8283
listenerSession: ListenerSession | null;
84+
sessionStatus: AppSessionStatus | null;
8385
}
8486

8587
export interface TranscriptSegment {
@@ -106,6 +108,14 @@ export interface ListenerSession {
106108
error: string | null;
107109
}
108110

111+
export interface AppSessionStatus {
112+
state: AppSessionState;
113+
detail: string | null;
114+
retryableChunkCount: number;
115+
failedChunkCount: number;
116+
runningChunkCount: number;
117+
}
118+
109119
export interface STTChunk {
110120
sessionID: string | null;
111121
chunkID: string;

0 commit comments

Comments
 (0)