d3ro-voice/apps/desktop/src/main/voice-session-persist.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

75 lines
2.8 KiB
TypeScript

// VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장.
// bootstrap과 테스트가 같은 함수를 호출한다.
import { app } from 'electron'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getHistoryService } from './services/HistoryService'
import { getLogger } from './services/LoggerService'
import { routeCompletedVoiceText } from './voice-text-routing'
import type { HistoryEntry } from '@d3ro/core/types'
const logger = getLogger('voice-persist')
export interface PersistableVoiceSession {
id: string
transcription: string
processedText: string | null
mode: string
startedAt: number
}
/**
* @param audioPath VoiceMode 가 완료 전에 실제로 저장한 녹음 경로(RecordingStore.save 결과).
* 오디오가 없거나 저장에 실패했으면 null — 존재하지 않는 경로를 히스토리에 남기지 않는다.
*/
export function persistCompletedVoiceSession(
session: PersistableVoiceSession,
finalText: string,
audioPath: string | null = null,
): HistoryEntry {
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
const mode = session.mode === 'hands-free' ? 'dictation' : session.mode
return getHistoryService().create({
originalText: session.transcription || finalText,
polishedText: session.processedText,
mode: mode as HistoryEntry['mode'],
status: 'completed',
duration: (Date.now() - session.startedAt) / 1000,
wordCount,
audioLocalPath: audioPath,
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.0.0',
})
}
/**
* 완료된 받아쓰기 결과를 소비자(진행 중인 받아쓰기 템플릿 세션의 현재 필드 등)에게 넘긴다.
* 소비되면 true — 일반 삽입 경로는 isVoiceSessionClaimed(session.id)로 이를 확인한다.
*/
export function routeCompletedVoiceSession(session: PersistableVoiceSession, finalText: string): boolean {
return routeCompletedVoiceText(session.id, finalText)
}
export function persistCompletedVoiceSessionSafe(
emitError: (error: D3ROError, session: PersistableVoiceSession) => void,
session: PersistableVoiceSession,
finalText: string,
audioPath: string | null = null,
): HistoryEntry | null {
// 히스토리 저장이 실패해도 결과는 소비자에게 전달돼야 하므로 먼저 라우팅한다
routeCompletedVoiceSession(session, finalText)
try {
return persistCompletedVoiceSession(session, finalText, audioPath)
} catch (error) {
const d3ro =
error instanceof D3ROError
? error
: new D3ROError(
ErrorCode.DBWriteFailed,
`Failed to save history: ${error instanceof Error ? error.message : String(error)}`,
)
logger.error(d3ro.message)
emitError(d3ro, session)
return null
}
}