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.
This commit is contained in:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -36,7 +36,7 @@ from typing import AsyncGenerator
|
|||
|
||||
import numpy as np
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from device_policy import (
|
||||
|
|
@ -79,6 +79,21 @@ _model_devices: dict[str, DeviceChoice] = {}
|
|||
_server: uvicorn.Server | None = None
|
||||
_models_dir: Path | None = None
|
||||
|
||||
# 모델 추론·로딩은 한 번에 하나만 돈다(모델 교체와 전사가 겹치지 않게). 무거운 작업은
|
||||
# asyncio.to_thread 로 돌려, 긴 전사·로딩 중에도 /health · /uia/focus · /download/status 가 응답한다.
|
||||
# (예전엔 async 핸들러 안에서 동기로 돌아 이벤트 루프 전체가 막혔다.)
|
||||
_inference_lock = asyncio.Lock()
|
||||
# 클라이언트가 끊긴 전사를 확인하는 주기(초)
|
||||
_DISCONNECT_POLL_SECONDS = 0.5
|
||||
|
||||
|
||||
class TranscriptionCancelled(Exception):
|
||||
"""클라이언트가 연결을 끊어 전사를 중단했다 (타임아웃·취소)."""
|
||||
|
||||
|
||||
class ModelNotInstalled(Exception):
|
||||
"""models-dir 에도 HF 캐시에도 없는 모델 — 암묵적으로 내려받지 않는다."""
|
||||
|
||||
# ── 다운로드 상태 (스레드 공유) ────────────────────────────
|
||||
|
||||
_download_lock = threading.Lock()
|
||||
|
|
@ -352,6 +367,7 @@ def _run_transcription(
|
|||
model: "WhisperModel",
|
||||
audio_array: np.ndarray,
|
||||
transcribe_kwargs: dict,
|
||||
cancel: threading.Event | None = None,
|
||||
) -> tuple[list[dict], str, str]:
|
||||
"""모델로 전사하고 세그먼트를 끝까지 소비한다.
|
||||
|
||||
|
|
@ -374,6 +390,9 @@ def _run_transcription(
|
|||
full_text_parts: list[str] = []
|
||||
|
||||
for segment in segments_iter:
|
||||
# 클라이언트가 떠났으면(타임아웃·취소) 남은 디코딩을 버린다 — 끝까지 돌면 다음 받아쓰기가 막힌다.
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise TranscriptionCancelled("client disconnected")
|
||||
seg_dict = {
|
||||
"text": segment.text.strip(),
|
||||
"start": round(segment.start, 3),
|
||||
|
|
@ -486,6 +505,22 @@ def _reload_on_cpu(model_id: str, is_primary: bool) -> "WhisperModel":
|
|||
return model
|
||||
|
||||
|
||||
def _ensure_model_available(model_id: str) -> None:
|
||||
"""models-dir 을 쓰는 설치본에서, 받지 않은 모델을 /load 가 HF 에서 몰래 내려받지 않게 한다.
|
||||
|
||||
models-dir 에 있거나 HF 캐시에 이미 있으면 통과한다. models-dir 을 지정하지 않은 실행(개발·테스트)은
|
||||
예전처럼 faster-whisper 에 맡긴다(HF 캐시만 사용).
|
||||
"""
|
||||
if _models_dir is None or _local_model_dir(model_id) is not None:
|
||||
return
|
||||
try:
|
||||
from faster_whisper.utils import download_model
|
||||
|
||||
download_model(model_id, local_files_only=True)
|
||||
except Exception as exc: # noqa: BLE001 — 캐시에 없으면 여러 종류의 예외가 난다
|
||||
raise ModelNotInstalled(f"모델이 설치되어 있지 않습니다: {model_id}") from exc
|
||||
|
||||
|
||||
@app.post("/load")
|
||||
async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
||||
"""Whisper 모델을 로딩한다.
|
||||
|
|
@ -518,17 +553,28 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
|||
)
|
||||
|
||||
try:
|
||||
if slot == "aux":
|
||||
# 보조 자리는 하나만 둔다 — 다른 보조 모델은 내려 VRAM 을 돌려받는다.
|
||||
_aux_models.clear()
|
||||
_aux_models[model_id] = _load_on_best_device(model_id)
|
||||
else:
|
||||
# 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다.
|
||||
_model = None
|
||||
_model = _load_on_best_device(model_id)
|
||||
_model_id = model_id
|
||||
# 기본 모델이 된 모델은 보조 자리에 중복으로 들고 있지 않는다.
|
||||
_aux_models.pop(model_id, None)
|
||||
# 설치하지 않은 모델은 올리지 않는다 — 쓰던 모델을 내리기 전에 확인한다.
|
||||
await asyncio.to_thread(_ensure_model_available, model_id)
|
||||
except ModelNotInstalled as exc:
|
||||
logger.warning("모델 로딩 거부 (미설치): %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "code": "model_not_installed", "message": str(exc)},
|
||||
)
|
||||
|
||||
try:
|
||||
async with _inference_lock:
|
||||
if slot == "aux":
|
||||
# 보조 자리는 하나만 둔다 — 다른 보조 모델은 내려 VRAM 을 돌려받는다.
|
||||
_aux_models.clear()
|
||||
_aux_models[model_id] = await asyncio.to_thread(_load_on_best_device, model_id)
|
||||
else:
|
||||
# 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다.
|
||||
_model = None
|
||||
_model = await asyncio.to_thread(_load_on_best_device, model_id)
|
||||
_model_id = model_id
|
||||
# 기본 모델이 된 모델은 보조 자리에 중복으로 들고 있지 않는다.
|
||||
_aux_models.pop(model_id, None)
|
||||
|
||||
load_time_ms = int((time.monotonic() - start_time) * 1000)
|
||||
logger.info("모델 로딩 완료: %s (slot=%s, %dms)", model_id, slot, load_time_ms)
|
||||
|
|
@ -549,8 +595,52 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
|||
)
|
||||
|
||||
|
||||
async def _watch_disconnect(request: Request, cancel: threading.Event) -> None:
|
||||
"""클라이언트가 연결을 끊으면(타임아웃 abort 등) cancel 을 세운다."""
|
||||
while not cancel.is_set():
|
||||
if await request.is_disconnected():
|
||||
cancel.set()
|
||||
return
|
||||
await asyncio.sleep(_DISCONNECT_POLL_SECONDS)
|
||||
|
||||
|
||||
def _transcribe_blocking(
|
||||
model: "WhisperModel",
|
||||
audio_array: np.ndarray,
|
||||
transcribe_kwargs: dict,
|
||||
resolved_model_id: str | None,
|
||||
is_primary: bool,
|
||||
cancel: threading.Event,
|
||||
) -> tuple[list[dict], str, str]:
|
||||
"""워커 스레드에서 전사한다 (GPU 런타임 오류면 CPU 로 다시 올려 한 번만 재시도)."""
|
||||
try:
|
||||
return _run_transcription(model, audio_array, dict(transcribe_kwargs), cancel)
|
||||
except TranscriptionCancelled:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# cuBLAS 미설치 PC 등: 검증을 통과했더라도 실제 전사에서 GPU 런타임 오류가
|
||||
# 나면 이 모델을 CPU 로 다시 올려 한 번만 재시도한다.
|
||||
device = _model_devices.get(resolved_model_id or "")
|
||||
if not (
|
||||
resolved_model_id
|
||||
and device is not None
|
||||
and device.is_gpu
|
||||
and is_gpu_runtime_error(exc)
|
||||
):
|
||||
raise
|
||||
logger.warning(
|
||||
"전사 중 GPU 런타임 오류 → CPU 로 재로딩 후 재시도 (%s): %s",
|
||||
resolved_model_id,
|
||||
exc,
|
||||
)
|
||||
_disable_gpu(exc)
|
||||
cpu_model = _reload_on_cpu(resolved_model_id, is_primary)
|
||||
return _run_transcription(cpu_model, audio_array, dict(transcribe_kwargs), cancel)
|
||||
|
||||
|
||||
@app.post("/transcribe")
|
||||
async def transcribe(
|
||||
request: Request,
|
||||
audio: UploadFile = File(...),
|
||||
language: str = Form("auto"),
|
||||
vad_filter: str = Form("true"),
|
||||
|
|
@ -619,31 +709,24 @@ async def transcribe(
|
|||
is_partial=is_partial,
|
||||
)
|
||||
|
||||
cancel = threading.Event()
|
||||
watcher = asyncio.create_task(_watch_disconnect(request, cancel))
|
||||
try:
|
||||
segments_list, full_text, detected_language = _run_transcription(
|
||||
model, audio_array, dict(transcribe_kwargs)
|
||||
)
|
||||
except Exception as exc:
|
||||
# cuBLAS 미설치 PC 등: 검증을 통과했더라도 실제 전사에서 GPU 런타임 오류가
|
||||
# 나면 이 모델을 CPU 로 다시 올려 한 번만 재시도한다.
|
||||
device = _model_devices.get(resolved_model_id or "")
|
||||
if not (
|
||||
resolved_model_id
|
||||
and device is not None
|
||||
and device.is_gpu
|
||||
and is_gpu_runtime_error(exc)
|
||||
):
|
||||
raise
|
||||
logger.warning(
|
||||
"전사 중 GPU 런타임 오류 → CPU 로 재로딩 후 재시도 (%s): %s",
|
||||
resolved_model_id,
|
||||
exc,
|
||||
)
|
||||
_disable_gpu(exc)
|
||||
model = _reload_on_cpu(resolved_model_id, is_primary)
|
||||
segments_list, full_text, detected_language = _run_transcription(
|
||||
model, audio_array, dict(transcribe_kwargs)
|
||||
)
|
||||
async with _inference_lock:
|
||||
if cancel.is_set():
|
||||
raise TranscriptionCancelled("client disconnected while queued")
|
||||
segments_list, full_text, detected_language = await asyncio.to_thread(
|
||||
_transcribe_blocking,
|
||||
model,
|
||||
audio_array,
|
||||
transcribe_kwargs,
|
||||
resolved_model_id,
|
||||
is_primary,
|
||||
cancel,
|
||||
)
|
||||
finally:
|
||||
cancel.set()
|
||||
watcher.cancel()
|
||||
|
||||
processing_time = int((time.monotonic() - start_time) * 1000)
|
||||
|
||||
|
|
@ -665,6 +748,12 @@ async def transcribe(
|
|||
}
|
||||
)
|
||||
|
||||
except TranscriptionCancelled as exc:
|
||||
logger.info("전사 중단: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=499,
|
||||
content={"status": "error", "code": "cancelled", "message": str(exc)},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("전사 실패: %s", exc, exc_info=True)
|
||||
return JSONResponse(
|
||||
|
|
|
|||
134
apps/desktop/sidecar/tests/test_sidecar_redteam_r3_1.py
Normal file
134
apps/desktop/sidecar/tests/test_sidecar_redteam_r3_1.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""
|
||||
사이드카 회귀 테스트 (r3-1).
|
||||
|
||||
- models-dir 을 쓰는 설치본에서 받지 않은 모델을 /load 하면 HF 에서 몰래 내려받지 않고 404 로 거부하며,
|
||||
쓰던 기본 모델을 내리지 않는다.
|
||||
- 클라이언트가 떠난 전사(cancel)는 세그먼트 반복을 멈춘다 — 끝까지 디코딩하며 다음 요청을 막지 않는다.
|
||||
|
||||
실행 (apps/desktop/sidecar 에서):
|
||||
.venv/Scripts/python.exe -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
from unittest import mock
|
||||
|
||||
SIDECAR_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(SIDECAR_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SIDECAR_DIR))
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
import main # noqa: E402
|
||||
|
||||
|
||||
class _Segment:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.start = 0.0
|
||||
self.end = 1.0
|
||||
self.avg_logprob = -0.1
|
||||
|
||||
|
||||
class _Info:
|
||||
language = "ko"
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
created: list["_FakeModel"] = []
|
||||
|
||||
def __init__(self, source: str, **_kwargs: object) -> None:
|
||||
self.source = source
|
||||
_FakeModel.created.append(self)
|
||||
|
||||
def transcribe(self, _audio: np.ndarray, **_kwargs: object) -> tuple[Iterator[_Segment], _Info]:
|
||||
def gen() -> Iterator[_Segment]:
|
||||
for index in range(5):
|
||||
yield _Segment(f"segment {index}")
|
||||
|
||||
return gen(), _Info()
|
||||
|
||||
|
||||
class ImplicitDownloadGuardTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_FakeModel.created = []
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
main._models_dir = Path(self._tmp.name)
|
||||
main._gpu_available = False
|
||||
main._gpu_choice = None
|
||||
main._aux_models.clear()
|
||||
main._model_devices.clear()
|
||||
self.previous = _FakeModel("turbo")
|
||||
main._model = self.previous # type: ignore[assignment]
|
||||
main._model_id = "large-v3-turbo"
|
||||
|
||||
fake_utils = types.ModuleType("faster_whisper.utils")
|
||||
|
||||
def download_model(model_id: str, local_files_only: bool = False, **_kwargs: object) -> str:
|
||||
if local_files_only:
|
||||
raise FileNotFoundError(f"{model_id} not in cache")
|
||||
raise AssertionError("must never download implicitly")
|
||||
|
||||
fake_utils.download_model = download_model # type: ignore[attr-defined]
|
||||
fake_module = types.ModuleType("faster_whisper")
|
||||
fake_module.WhisperModel = _FakeModel # type: ignore[attr-defined]
|
||||
fake_module.utils = fake_utils # type: ignore[attr-defined]
|
||||
self._patch = mock.patch.dict(
|
||||
sys.modules, {"faster_whisper": fake_module, "faster_whisper.utils": fake_utils}
|
||||
)
|
||||
self._patch.start()
|
||||
self.client = TestClient(main.app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._patch.stop()
|
||||
self._tmp.cleanup()
|
||||
main._models_dir = None
|
||||
main._model = None
|
||||
main._model_id = None
|
||||
main._aux_models.clear()
|
||||
main._model_devices.clear()
|
||||
|
||||
def test_uninstalled_model_is_rejected_without_unloading_primary(self) -> None:
|
||||
res = self.client.post("/load", json={"model_id": "large-v3"})
|
||||
self.assertEqual(res.status_code, 404)
|
||||
self.assertEqual(res.json()["code"], "model_not_installed")
|
||||
self.assertIs(main._model, self.previous)
|
||||
self.assertEqual(main._model_id, "large-v3-turbo")
|
||||
self.assertEqual(len(_FakeModel.created), 1)
|
||||
|
||||
def test_installed_model_in_models_dir_loads(self) -> None:
|
||||
model_dir = Path(self._tmp.name) / "small"
|
||||
model_dir.mkdir()
|
||||
(model_dir / "model.bin").write_bytes(b"x")
|
||||
with mock.patch.object(main, "_probe_model"):
|
||||
res = self.client.post("/load", json={"model_id": "small"})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(main._model_id, "small")
|
||||
|
||||
|
||||
class TranscriptionCancelTest(unittest.TestCase):
|
||||
def test_cancelled_transcription_stops_iterating_segments(self) -> None:
|
||||
cancel = threading.Event()
|
||||
cancel.set()
|
||||
with self.assertRaises(main.TranscriptionCancelled):
|
||||
main._run_transcription(_FakeModel("x"), np.zeros(16000, dtype=np.float32), {}, cancel)
|
||||
|
||||
def test_uncancelled_transcription_consumes_all_segments(self) -> None:
|
||||
segments, text, language = main._run_transcription(
|
||||
_FakeModel("x"), np.zeros(16000, dtype=np.float32), {}, threading.Event()
|
||||
)
|
||||
self.assertEqual(len(segments), 5)
|
||||
self.assertEqual(language, "ko")
|
||||
self.assertTrue(text.startswith("segment 0"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -163,6 +163,12 @@ if (!gotTheLock) {
|
|||
void handleDeepLink(url)
|
||||
})
|
||||
|
||||
// macOS: Dock 아이콘 클릭 → 트레이로 숨긴(closeToTray) 또는 닫힌 메인 창을 다시 띄운다.
|
||||
// 부트스트랩 전(launch 직후 첫 activate)에는 showMainWindow 가 창을 만들지 않는다.
|
||||
app.on('activate', () => {
|
||||
showMainWindow()
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await bootstrap()
|
||||
setupLifecycle()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ export function registerCaptionHandlers(): void {
|
|||
|
||||
ipcMain.handle(IPC_CHANNELS.CAPTION.STOP, async () => {
|
||||
try {
|
||||
await getCaptionService().stop()
|
||||
// 사용자 자막만 멈춘다. 소유자 없이 부르면 강제 정지라, 대시보드의 '정지'가 회의 전사·마이크를
|
||||
// 끄고도 회의는 '녹음 중'으로 남았다(강제 정지는 종료·로그아웃·회의 초기화 전용).
|
||||
const result = await getCaptionService().stop('user')
|
||||
if (result === false) {
|
||||
return ipcError(ErrorCode.CaptionAlreadyActive, 'Caption is in use by meeting recording')
|
||||
}
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ export function registerInputTelemetryHandlers(): void {
|
|||
async (_event, params: SetInputTelemetryEnabledParams) => {
|
||||
try {
|
||||
getInputTelemetryService().setEnabled(params.enabled === true)
|
||||
// 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다.
|
||||
if (params.enabled !== true) getSuggestionService().applyConfig()
|
||||
// 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다 — 떠 있는 세션·채우기·보관 문맥을 정리한다.
|
||||
getSuggestionService().handleInputConsentChanged()
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
|
|
@ -62,6 +62,7 @@ export function registerInputTelemetryHandlers(): void {
|
|||
async (_event, params: SetInputTelemetryPausedParams) => {
|
||||
try {
|
||||
getInputTelemetryService().setPaused(params.paused === true)
|
||||
getSuggestionService().handleInputConsentChanged()
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ export function registerSTTHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.STT.SET_MODEL, async (_event, params: SetSTTModelParams) => {
|
||||
try {
|
||||
configSet('sttModelId', params.modelId)
|
||||
if (getSTTManager().getActiveProvider() === 'local') {
|
||||
await getLocalSTTService().initialize(params.modelId)
|
||||
// 설치된 모델만 바로 올린다. 미설치 모델을 initialize 하면 사이드카 /load 가 쓰던 모델을 내리고
|
||||
// HF 캐시로 몇 GB 를 몰래 받는다 — 설정만 저장하고, 사용자가 '다운로드' 로 받게 둔다.
|
||||
const stt = getLocalSTTService()
|
||||
if (getSTTManager().getActiveProvider() === 'local' && stt.isModelInstalled(params.modelId)) {
|
||||
await stt.initialize(params.modelId)
|
||||
}
|
||||
return ipcSuccess(undefined)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ipcMain } from 'electron'
|
|||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors'
|
||||
import { getVoiceConversationService } from '../services/VoiceConversationService'
|
||||
import { getCloudSyncService } from '../services/CloudSyncService'
|
||||
import { cloudSyncCredentials, type CloudCredentials } from '../services/cloud/cloud-credentials'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type {
|
||||
ConversationSendParams,
|
||||
|
|
@ -15,7 +15,7 @@ import type {
|
|||
|
||||
const logger = getLogger('voice-conversation-handlers')
|
||||
|
||||
export function registerVoiceConversationHandlers(): void {
|
||||
export function registerVoiceConversationHandlers(credentials: CloudCredentials = cloudSyncCredentials): void {
|
||||
ipcMain.handle(IPC_CHANNELS.VOICE_CONVERSATION.START_SESSION, async () => {
|
||||
try {
|
||||
await getVoiceConversationService().startSession()
|
||||
|
|
@ -94,7 +94,7 @@ export function registerVoiceConversationHandlers(): void {
|
|||
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
|
||||
async (_event, params: RealtimeTokenParams) => {
|
||||
try {
|
||||
const { data, error } = await getCloudSyncService().invokeFunction(
|
||||
const { data, error } = await credentials.invoke(
|
||||
'realtime-token',
|
||||
{ ...params },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ import { getMainWindow } from '../windows/WindowManager'
|
|||
|
||||
const logger = getLogger('CaptionService')
|
||||
|
||||
/** 로딩을 기다리던 start() 가 stop()·새 start 로 무효화됐다 (내부 신호) */
|
||||
class CaptionStartCancelledError extends Error {
|
||||
constructor() {
|
||||
super('caption start cancelled')
|
||||
this.name = 'CaptionStartCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
/** 트랙 판단 주기 (ms) — 인식 주기는 트랙이 정한다(중간 결과 1초) */
|
||||
const TICK_INTERVAL_MS = 250
|
||||
|
||||
|
|
@ -123,6 +131,11 @@ class CaptionService extends EventEmitter {
|
|||
private _audio: CaptionAudioSources = this._createAudioSources()
|
||||
/** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */
|
||||
private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS }
|
||||
/**
|
||||
* 시작 세대. start() 마다 올리고, 'starting' 중 stop() 도 올린다 — 모델 로딩을 기다리던 start 가
|
||||
* 깨어났을 때 세대가 바뀌었으면 마이크를 열지 않고 물러난다(취소한 자막이 오버레이 없이 켜지던 문제).
|
||||
*/
|
||||
private _startEpoch = 0
|
||||
|
||||
// ── 공개 접근자 ──
|
||||
|
||||
|
|
@ -169,6 +182,11 @@ class CaptionService extends EventEmitter {
|
|||
showOverlay: options.showOverlay ?? DEFAULT_START_OPTIONS.showOverlay,
|
||||
persistHistory: options.persistHistory ?? DEFAULT_START_OPTIONS.persistHistory,
|
||||
}
|
||||
const epoch = ++this._startEpoch
|
||||
const assertCurrent = (): void => {
|
||||
if (epoch !== this._startEpoch || this._disposed) throw new CaptionStartCancelledError()
|
||||
}
|
||||
let ownAudio: CaptionAudioSources | null = null
|
||||
this._setState('starting')
|
||||
|
||||
// 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다
|
||||
|
|
@ -182,9 +200,11 @@ class CaptionService extends EventEmitter {
|
|||
// STT 초기화 (모델 로딩 — 시간 소요)
|
||||
const sttService = getLocalSTTService()
|
||||
await sttService.initialize()
|
||||
assertCurrent()
|
||||
// 자막 전용 모델을 골랐으면 받아쓰기 모델과 별도로 올려 둔다(보조 자리).
|
||||
this._captionModelId = configGet('captionSttModelId') ?? null
|
||||
if (this._captionModelId) await sttService.ensureAuxModel(this._captionModelId)
|
||||
assertCurrent()
|
||||
|
||||
// 세션 초기화
|
||||
this._sessionId = crypto.randomUUID()
|
||||
|
|
@ -218,8 +238,10 @@ class CaptionService extends EventEmitter {
|
|||
this._systemTrack = this._createTrack('system', initialContext)
|
||||
}
|
||||
// 마이크 캡처(mic/both) + 시스템 오디오 캡처 요청(system/both — 렌더러에 시작 요청)
|
||||
this._audio = this._createAudioSources()
|
||||
await this._audio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) })
|
||||
ownAudio = this._createAudioSources()
|
||||
this._audio = ownAudio
|
||||
await ownAudio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) })
|
||||
assertCurrent()
|
||||
|
||||
// 트랙 판단 타이머 — 각 트랙은 한 번에 인식 한 건만 돌린다
|
||||
this._tickTimer = setInterval(() => {
|
||||
|
|
@ -231,6 +253,13 @@ class CaptionService extends EventEmitter {
|
|||
getSoundEffectService().play('recording-start')
|
||||
logger.info(`Live Caption 시작: sessionId=${this._sessionId}`)
|
||||
} catch (err) {
|
||||
if (err instanceof CaptionStartCancelledError) {
|
||||
// 기다리는 사이 stop()(또는 새 start)이 세션을 가져갔다 — 공유 상태는 건드리지 않고
|
||||
// 이 시도가 연 자원만 돌려준다(release 는 멱등).
|
||||
await ownAudio?.release()
|
||||
logger.info('캡션 시작 취소: 로딩 중 정지됨')
|
||||
throw new D3ROError(ErrorCode.CaptionStartFailed, '캡션 시작이 취소되었습니다')
|
||||
}
|
||||
if (this._tickTimer) {
|
||||
clearInterval(this._tickTimer)
|
||||
this._tickTimer = null
|
||||
|
|
@ -272,6 +301,8 @@ class CaptionService extends EventEmitter {
|
|||
return false
|
||||
}
|
||||
|
||||
// 로딩 중(start 가 모델을 기다리는 중)이면 그 start 를 무효화한다 — 깨어나도 마이크를 열지 않는다.
|
||||
if (this._state === 'starting') this._startEpoch += 1
|
||||
this._setState('stopping')
|
||||
|
||||
// 타이머 정리
|
||||
|
|
|
|||
|
|
@ -143,32 +143,11 @@ class CloudSyncService extends EventEmitter {
|
|||
if (this._initialized) return
|
||||
this._initialized = true
|
||||
|
||||
this._client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: false, // 직접 관리
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: false
|
||||
},
|
||||
// Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이
|
||||
// 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이
|
||||
// 실시간으로 오지 않던 원인.
|
||||
realtime: { transport: nodeRealtimeTransport }
|
||||
})
|
||||
this._client = this._createClient()
|
||||
|
||||
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
|
||||
|
||||
// supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을
|
||||
// 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다.
|
||||
const client = this._client
|
||||
this._unbindAuthEvents = bindAuthEvents<Session>(
|
||||
(callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription,
|
||||
this._tokens(),
|
||||
() => this._session?.user.id ?? null,
|
||||
(session) => {
|
||||
this._session = session
|
||||
},
|
||||
() => this._onRemoteSignedOut()
|
||||
)
|
||||
this._bindClientAuthEvents(this._client)
|
||||
|
||||
// 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외).
|
||||
onConfigChanged((event) => {
|
||||
|
|
@ -199,6 +178,62 @@ class CloudSyncService extends EventEmitter {
|
|||
logger.info('CloudSyncService initialized')
|
||||
}
|
||||
|
||||
private _createClient(): SupabaseClient {
|
||||
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: false, // 직접 관리
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: false
|
||||
},
|
||||
// Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이
|
||||
// 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이
|
||||
// 실시간으로 오지 않던 원인.
|
||||
realtime: { transport: nodeRealtimeTransport }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을
|
||||
* 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다.
|
||||
*/
|
||||
private _bindClientAuthEvents(client: SupabaseClient): void {
|
||||
this._unbindAuthEvents?.()
|
||||
this._unbindAuthEvents = bindAuthEvents<Session>(
|
||||
(callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription,
|
||||
this._tokens(),
|
||||
() => this._session?.user.id ?? null,
|
||||
(session) => {
|
||||
this._session = session
|
||||
},
|
||||
() => this._onRemoteSignedOut()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 기기의 Supabase 세션을 서버 결과와 무관하게 버린다.
|
||||
* auth-js signOut 은 오프라인·5xx 면 { error } 를 돌려주고 _removeSession 을 건너뛰어, 클라이언트가
|
||||
* 옛 계정 세션을 계속 들고 자동 갱신한다(Cloud STT·realtime-token 이 로그아웃 뒤에도 그 계정으로 돌던 원인).
|
||||
* 공개 API 로는 메모리 세션만 지울 수 없으므로, 실패하면 자동 갱신을 멈추고 클라이언트를 새로 만든다.
|
||||
*/
|
||||
private async _revokeLocalSession(): Promise<void> {
|
||||
const client = this._client
|
||||
if (!client) return
|
||||
let failure: string | null = null
|
||||
try {
|
||||
const { error } = await client.auth.signOut({ scope: 'local' })
|
||||
if (error) failure = error.message
|
||||
} catch (err) {
|
||||
failure = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
if (failure === null) return
|
||||
logger.warn(`signOut did not clear the client session (${failure}) — discarding the auth client`)
|
||||
await client.auth.stopAutoRefresh().catch(() => undefined)
|
||||
if (this._client !== client) return
|
||||
const fresh = this._createClient()
|
||||
this._client = fresh
|
||||
this._bindClientAuthEvents(fresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 계정 id (없거나 구버전 토큰 파일이면 null). bootstrap 이 세션 복원을 기다리지 않고
|
||||
* 그 계정 DB 를 먼저 열어, 복원 중·오프라인 동안의 기록이 익명 DB 로 새지 않게 한다.
|
||||
|
|
@ -542,12 +577,9 @@ class CloudSyncService extends EventEmitter {
|
|||
|
||||
// 4) Supabase 세션 무효화 — 이 기기의 세션만(scope:'local').
|
||||
// 기본값 'global' 은 계정의 모든 refresh token 을 폐기해 휴대폰·다른 PC 까지 로그아웃시켰다.
|
||||
// 서버가 실패를 돌려줘도 클라이언트의 세션은 반드시 버린다(_revokeLocalSession).
|
||||
if (this._client && this._session) {
|
||||
try {
|
||||
await this._client.auth.signOut({ scope: 'local' })
|
||||
} catch (err) {
|
||||
logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
await this._revokeLocalSession()
|
||||
}
|
||||
|
||||
// 4) in-memory 상태 + 저장된 토큰 clear
|
||||
|
|
@ -611,9 +643,22 @@ class CloudSyncService extends EventEmitter {
|
|||
* refresh된 토큰은 _session에 반영되지 않아 stale JWT가 될 수 있음.
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
if (!this._client) return null
|
||||
const { data } = await this._client.auth.getSession()
|
||||
return data.session?.access_token ?? null
|
||||
return this._currentAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 자격 증명의 단일 관문 — 이 서비스가 로그아웃 상태(_session=null)면 클라이언트에 무엇이 남아 있든 null.
|
||||
* 기다리는 동안 계정이 바뀌거나 로그아웃되면 그 토큰도 버린다.
|
||||
*/
|
||||
private async _currentAccessToken(): Promise<string | null> {
|
||||
const client = this._client
|
||||
const session = this._session
|
||||
if (!client || !session) return null
|
||||
const { data } = await client.auth.getSession()
|
||||
if (this._client !== client || this._session?.user.id !== session.user.id) return null
|
||||
const current = data.session
|
||||
if (!current || current.user?.id !== session.user.id) return null
|
||||
return current.access_token ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -646,10 +691,9 @@ class CloudSyncService extends EventEmitter {
|
|||
return { data: null, error: { message: 'Supabase client not initialized' } }
|
||||
}
|
||||
|
||||
// 최신 세션 확보 (auto-refresh 보장)
|
||||
const { data: sessionData } = await this._client.auth.getSession()
|
||||
const token = sessionData.session?.access_token
|
||||
if (!token) {
|
||||
// 최신 세션 확보 (auto-refresh 보장) — 로그아웃 상태면 클라이언트에 남은 세션을 쓰지 않는다
|
||||
const token = await this._currentAccessToken()
|
||||
if (!token || !this._client) {
|
||||
return { data: null, error: { message: 'No active session — 로그인 필요' } }
|
||||
}
|
||||
|
||||
|
|
@ -689,8 +733,7 @@ class CloudSyncService extends EventEmitter {
|
|||
return { stream: null, error: { message: 'Supabase client not initialized' } }
|
||||
}
|
||||
|
||||
const { data: sessionData } = await this._client.auth.getSession()
|
||||
const token = sessionData.session?.access_token
|
||||
const token = await this._currentAccessToken()
|
||||
if (!token) {
|
||||
return { stream: null, error: { message: 'No active session — 로그인 필요' } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getMainWindow } from '../windows/WindowManager'
|
|||
import { registerVoiceTextSink } from '../voice-text-routing'
|
||||
import { dictatedFieldValue, nextTemplateField, renderTemplateOutput } from './dictation-template-policy'
|
||||
import { textInsertTemplateOutputPort, type TemplateOutputPort } from './dictation-template-output'
|
||||
import { findTemplateFieldIdProblem } from '@d3ro/core/template-field-policy'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
|
|
@ -115,6 +116,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
|
||||
create(params: CreateTemplateParams): DictationTemplate {
|
||||
assertValidFieldIds(params.fields)
|
||||
const template: DictationTemplate = {
|
||||
id: crypto.randomUUID(),
|
||||
name: params.name,
|
||||
|
|
@ -135,6 +137,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
|
||||
update(params: UpdateTemplateParams): DictationTemplate {
|
||||
if (params.fields !== undefined) assertValidFieldIds(params.fields)
|
||||
const templates = this.getAll()
|
||||
const idx = templates.findIndex((t) => t.id === params.id)
|
||||
if (idx === -1) {
|
||||
|
|
@ -360,6 +363,21 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 id는 받아쓴 값의 키이자 {{id}} 자리표시자 키다. 비었거나 겹치면 나중 필드 값이 앞 필드 값을 덮어써
|
||||
* 출력에서 앞 값이 사라지므로 저장 전에 거부한다.
|
||||
*/
|
||||
function assertValidFieldIds(fields: DictationTemplate['fields']): void {
|
||||
const problem = findTemplateFieldIdProblem(fields)
|
||||
if (!problem) return
|
||||
throw new D3ROError(
|
||||
ErrorCode.TemplateInvalidFormat,
|
||||
problem.problem === 'empty'
|
||||
? `Template field ${problem.index + 1} has an empty id`
|
||||
: `Template field id is not unique: ${problem.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 계정 범위가 생기기 전의 기계 전역 저장소(dictation-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */
|
||||
function readLegacyTemplates(): DictationTemplate[] | null {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ import { containsPattern } from '../db/like-pattern'
|
|||
import {
|
||||
buildDictionaryUpdatePatch,
|
||||
dictionaryIdentityKey,
|
||||
dictionaryWriteProblemError,
|
||||
findDictionaryIdentityConflict,
|
||||
isUniqueConstraintViolation,
|
||||
normalizeDictionaryPronunciation,
|
||||
normalizeDictionaryWord
|
||||
validateDictionaryDraft
|
||||
} from '@d3ro/core/dictionary-policy'
|
||||
import {
|
||||
parseDictionaryFile,
|
||||
serializeDictionary,
|
||||
toDictionaryImportRow
|
||||
} from './dictionary/dictionary-file-codec'
|
||||
import type {
|
||||
DictionaryEntry,
|
||||
DictionaryQueryParams,
|
||||
|
|
@ -37,90 +42,6 @@ const logger = getLogger('DictionaryService')
|
|||
|
||||
let isShowingDictionarySaveDialog = false
|
||||
|
||||
const DICTIONARY_CSV_HEADER = [
|
||||
'word',
|
||||
'pronunciation',
|
||||
'category',
|
||||
'usageCount',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
] as const
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
|
||||
return needsQuotes ? `"${escaped}"` : escaped
|
||||
}
|
||||
|
||||
function parseCsvRows(input: string): string[][] {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (input[i + 1] === '"') {
|
||||
field += '"'
|
||||
i += 1
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = true
|
||||
} else if (char === ',') {
|
||||
row.push(field)
|
||||
field = ''
|
||||
} else if (char === '\n') {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
row = []
|
||||
field = ''
|
||||
} else if (char === '\r') {
|
||||
// skip CR; LF terminates the row
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
}
|
||||
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
|
||||
}
|
||||
|
||||
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickNumber(source: Record<string, unknown>, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string | null): DictionaryEntry['category'] {
|
||||
if (value === 'auto' || value === 'technical' || value === 'user') return value
|
||||
return 'user'
|
||||
}
|
||||
|
||||
type DictionaryDb = Pick<ReturnType<typeof getDatabase>, 'select'>
|
||||
|
||||
/**
|
||||
|
|
@ -160,10 +81,11 @@ function findImportDuplicate(
|
|||
|
||||
class DictionaryService {
|
||||
add(params: DictionaryAddParams): DictionaryEntry {
|
||||
const word = normalizeDictionaryWord(params.word)
|
||||
if (!word) {
|
||||
throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty')
|
||||
}
|
||||
// 빈 단어·서버 길이 제한(단어 120·발음 200자) 초과는 저장 전에 거부한다 — 받아들이면 로컬에만 남고
|
||||
// 서버가 22023 으로 거부해 아웃박스에 영구 보류된다(다른 기기로 동기화되지 않음).
|
||||
const validated = validateDictionaryDraft(params)
|
||||
if (!validated.ok) throw dictionaryWriteProblemError(validated)
|
||||
const { word, pronunciation } = validated.draft
|
||||
|
||||
const db = getDatabase()
|
||||
const now = Date.now()
|
||||
|
|
@ -177,7 +99,7 @@ class DictionaryService {
|
|||
const entry: NewDictionary = {
|
||||
id,
|
||||
word,
|
||||
pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null,
|
||||
pronunciation,
|
||||
category,
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
|
|
@ -201,9 +123,7 @@ class DictionaryService {
|
|||
|
||||
update(params: DictionaryUpdateParams): DictionaryEntry | null {
|
||||
const result = buildDictionaryUpdatePatch(params)
|
||||
if (!result.ok) {
|
||||
throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty')
|
||||
}
|
||||
if (!result.ok) throw dictionaryWriteProblemError(result)
|
||||
|
||||
const db = getDatabase()
|
||||
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||
|
|
@ -334,8 +254,7 @@ class DictionaryService {
|
|||
.all()
|
||||
.map((row) => this._toEntry(row))
|
||||
|
||||
const content =
|
||||
params.format === 'csv' ? this._serializeCsv(entries) : this._serializeJson(entries)
|
||||
const content = serializeDictionary(entries, params.format)
|
||||
const defaultName = `d3ro-dictionary-${new Date().toISOString().slice(0, 10)}.${params.format}`
|
||||
|
||||
if (isShowingDictionarySaveDialog) {
|
||||
|
|
@ -398,7 +317,7 @@ class DictionaryService {
|
|||
|
||||
let parsed: Array<Record<string, unknown>>
|
||||
try {
|
||||
parsed = params.format === 'json' ? this._parseJson(raw) : this._parseCsv(raw)
|
||||
parsed = parseDictionaryFile(raw, params.format)
|
||||
} catch (err) {
|
||||
if (err instanceof D3ROError) throw err
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
@ -413,37 +332,25 @@ class DictionaryService {
|
|||
const outcome: DictionaryImportResult = { imported: 0, skipped: 0, errors: 0 }
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const row of parsed) {
|
||||
const word = pickString(row, ['word'])
|
||||
if (!word) {
|
||||
for (const record of parsed) {
|
||||
// 빈 단어·서버 길이 제한 초과 행은 오류로 센다 — 로컬에만 남고 동기화되지 않는 항목을 만들지 않는다
|
||||
const candidate = toDictionaryImportRow(record, now)
|
||||
if (!candidate.ok) {
|
||||
outcome.errors += 1
|
||||
continue
|
||||
}
|
||||
const category = normalizeCategory(pickString(row, ['category']))
|
||||
const { row } = candidate
|
||||
// 대소문자만 다른 단어도 같은 단어로 본다(서버 유일성과 같은 기준)
|
||||
const existing = findImportDuplicate(tx, word, category)
|
||||
const existing = findImportDuplicate(tx, row.word, row.category)
|
||||
if (existing) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const createdAt = pickNumber(row, ['createdAt', 'created_at'])
|
||||
const updatedAt = pickNumber(row, ['updatedAt', 'updated_at'])
|
||||
const usageCount = pickNumber(row, ['usageCount', 'usage_count'])
|
||||
const lastUsedAt = pickNumber(row, ['lastUsedAt', 'last_used_at'])
|
||||
const id = crypto.randomUUID()
|
||||
try {
|
||||
tx.insert(dictionary)
|
||||
.values({
|
||||
id,
|
||||
word,
|
||||
pronunciation: pickString(row, ['pronunciation']),
|
||||
category,
|
||||
usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0,
|
||||
lastUsedAt,
|
||||
createdAt: createdAt ?? now,
|
||||
updatedAt: updatedAt ?? createdAt ?? now
|
||||
})
|
||||
.values({ id, ...row })
|
||||
.run()
|
||||
outcome.imported += 1
|
||||
void getCloudSyncService().pushOne('dictionary', id)
|
||||
|
|
@ -459,64 +366,6 @@ class DictionaryService {
|
|||
return outcome
|
||||
}
|
||||
|
||||
private _serializeJson(entries: DictionaryEntry[]): string {
|
||||
return JSON.stringify({ entries }, null, 2)
|
||||
}
|
||||
|
||||
private _serializeCsv(entries: DictionaryEntry[]): string {
|
||||
const lines = [DICTIONARY_CSV_HEADER.join(',')]
|
||||
for (const entry of entries) {
|
||||
lines.push(
|
||||
[
|
||||
csvCell(entry.word),
|
||||
csvCell(entry.pronunciation ?? ''),
|
||||
csvCell(entry.category),
|
||||
csvCell(entry.usageCount),
|
||||
csvCell(entry.createdAt),
|
||||
csvCell(entry.updatedAt)
|
||||
].join(',')
|
||||
)
|
||||
}
|
||||
return `\uFEFF${lines.join('\r\n')}\r\n`
|
||||
}
|
||||
|
||||
private _parseJson(raw: string): Array<Record<string, unknown>> {
|
||||
const data: unknown = JSON.parse(raw)
|
||||
let list: unknown
|
||||
if (Array.isArray(data)) {
|
||||
list = data
|
||||
} else if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
Array.isArray((data as { entries?: unknown }).entries)
|
||||
) {
|
||||
list = (data as { entries: unknown[] }).entries
|
||||
} else {
|
||||
throw new Error('expected an array or an object with an "entries" array')
|
||||
}
|
||||
return (list as unknown[]).filter(
|
||||
(item): item is Record<string, unknown> => !!item && typeof item === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
private _parseCsv(raw: string): Array<Record<string, unknown>> {
|
||||
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
|
||||
if (rows.length === 0) {
|
||||
throw new Error('empty CSV')
|
||||
}
|
||||
const header = rows[0].map((cell) => cell.trim())
|
||||
if (!header.includes('word')) {
|
||||
throw new Error('missing "word" column')
|
||||
}
|
||||
return rows.slice(1).map((cells) => {
|
||||
const record: Record<string, unknown> = {}
|
||||
header.forEach((key, index) => {
|
||||
record[key] = cells[index] ?? ''
|
||||
})
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
logger.info('DictionaryService disposed')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,12 @@ class HistoryService {
|
|||
const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80)
|
||||
if (title) {
|
||||
const db = getDatabase()
|
||||
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
const result = db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
// 제목을 만드는 사이 기록이 지워졌다 — 올리면 대기 중인 원격 삭제를 upsert 로 덮는다.
|
||||
if (result.changes === 0) {
|
||||
logger.info(`Auto title discarded: ${id} was deleted while the title was generating`)
|
||||
return null
|
||||
}
|
||||
logger.info(`Auto title generated: ${id} → "${title}"`)
|
||||
// Phase 3.3: 타이틀 업데이트 후 자동 push
|
||||
void getCloudSyncService().pushOne('history', id)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ import {
|
|||
calculateFrictionInsight,
|
||||
classifyKeyStroke,
|
||||
computeTypedDelta,
|
||||
isEditAtTextEnd,
|
||||
shouldLearnFromApp,
|
||||
countWords,
|
||||
emptyActivityBucket,
|
||||
extractPhrases,
|
||||
isAppExcluded,
|
||||
isLearnablePhrase,
|
||||
LEARNING_EXCLUDED_APPS,
|
||||
manhattanDistance,
|
||||
mergeActivityBucket,
|
||||
rankFlowWindows,
|
||||
|
|
@ -99,6 +99,11 @@ export interface TypingContext {
|
|||
editedSinceFocus: boolean
|
||||
/** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */
|
||||
typedRecently: boolean
|
||||
/**
|
||||
* prefix 가 실제 커서 앞 텍스트라고 믿을 수 있는가 — 케어렛 오프셋을 알면 true, 모르면
|
||||
* 마지막 편집이 문서 끝에서 일어났을 때만 true. 생략하면 true 로 본다(호환).
|
||||
*/
|
||||
caretReliable?: boolean
|
||||
}
|
||||
|
||||
interface InputTelemetryEvents {
|
||||
|
|
@ -181,6 +186,10 @@ class InputTelemetryService extends EventEmitter {
|
|||
private _lastFocusKey = ''
|
||||
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
|
||||
private _textAtFocus = ''
|
||||
/** 직전 스냅샷의 (포커스 키, 텍스트) — 케어렛을 모를 때 편집 위치 판정에 쓴다 */
|
||||
private _editProbe: { key: string; text: string } | null = null
|
||||
/** 마지막 편집이 문서 끝에서 일어났는가 (케어렛을 모를 때만 의미가 있다) */
|
||||
private _lastEditAtEnd = true
|
||||
/** 클릭 뒤 첫 스냅샷에서 기준선을 다시 잡는다 */
|
||||
private _rebaseFocusText = false
|
||||
private _lastActiveTickAt = 0
|
||||
|
|
@ -644,6 +653,16 @@ class InputTelemetryService extends EventEmitter {
|
|||
}
|
||||
context.editedSinceFocus = textForFocus !== this._textAtFocus
|
||||
|
||||
// 케어렛을 모르면 "문서 끝 = 커서" 가정이 맞는지 마지막 편집 위치로 확인한다.
|
||||
// 문서 중간을 고쳤다면 접두(문서 끝)는 커서와 무관하다 — 제안·수락을 막는다.
|
||||
if (this._editProbe === null || this._editProbe.key !== focusKey) {
|
||||
this._lastEditAtEnd = true
|
||||
} else if (this._editProbe.text !== textForFocus) {
|
||||
this._lastEditAtEnd = isEditAtTextEnd(this._editProbe.text, textForFocus)
|
||||
}
|
||||
this._editProbe = { key: focusKey, text: textForFocus }
|
||||
context.caretReliable = caretKnown || this._lastEditAtEnd
|
||||
|
||||
if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) {
|
||||
this._applyTypedDelta(snapshot)
|
||||
}
|
||||
|
|
@ -817,9 +836,8 @@ class InputTelemetryService extends EventEmitter {
|
|||
raw: string,
|
||||
meta: { appName: string | null; windowTitle: string | null; source: PhraseSource }
|
||||
): void {
|
||||
if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) {
|
||||
return
|
||||
}
|
||||
// 학습 제외 앱은 출처와 무관하다 — 터미널에서 수락한 제안(개발 지시)도 코퍼스에 넣지 않는다.
|
||||
if (!shouldLearnFromApp(meta.appName)) return
|
||||
const text = sanitizeSample(
|
||||
raw
|
||||
.split(/\r?\n/u)
|
||||
|
|
@ -1374,8 +1392,8 @@ class InputTelemetryService extends EventEmitter {
|
|||
* 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다.
|
||||
*/
|
||||
private _pruneUnlearnableCorpus(): void {
|
||||
const excluded = (source: string, appName: string | null): boolean =>
|
||||
source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS)
|
||||
// 학습과 같은 규칙(출처 무관) — 규칙 이전에 쌓인 제외 앱의 수락 제안도 걷어낸다.
|
||||
const excluded = (_source: string, appName: string | null): boolean => !shouldLearnFromApp(appName)
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
bindingKey,
|
||||
normalizeBinding
|
||||
} from '@d3ro/core/keybinding'
|
||||
import { ChordStateMachine } from '@d3ro/core/keybinding-runtime'
|
||||
import { AltGrPressDetector, ChordStateMachine } from '@d3ro/core/keybinding-runtime'
|
||||
import type {
|
||||
ChordBindingEntry,
|
||||
ChordClock,
|
||||
|
|
@ -266,6 +266,13 @@ class KeyBindingService extends EventEmitter {
|
|||
debug: (message) => logger.debug(message)
|
||||
})
|
||||
|
||||
/**
|
||||
* Windows AltGr(가짜 LCtrl + RAlt) 판별. 가짜 LCtrl 이 켠 Ctrl 을 걷어 RightAlt 단독
|
||||
* 바인딩이 매칭되게 하고, AltGr 눌림은 문자 입력일 수 있으므로 상태 머신에 보류를 요청한다.
|
||||
* 다른 플랫폼에는 가짜 LCtrl 이 없으므로 판정 결과를 쓰지 않는다(_handleKeyDown).
|
||||
*/
|
||||
private readonly _altGr = new AltGrPressDetector()
|
||||
|
||||
/** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */
|
||||
private _ownedAccelerators: Set<string> = new Set()
|
||||
|
||||
|
|
@ -491,6 +498,7 @@ class KeyBindingService extends EventEmitter {
|
|||
/** 눌림 · 더블프레스 · AltGr 보류 타이머를 모두 비운다. */
|
||||
private _clearRuntimeState(): void {
|
||||
this._machine.reset()
|
||||
this._altGr.reset()
|
||||
}
|
||||
|
||||
// ── uiohook 이벤트 → 정본 좌표계 ──────────────────────
|
||||
|
|
@ -499,13 +507,19 @@ class KeyBindingService extends EventEmitter {
|
|||
* uiohook 키보드 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
|
||||
* normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로
|
||||
* Right Alt 단독 바인딩도 그대로 매칭된다.
|
||||
*
|
||||
* isAltGr 이면 Ctrl 플래그는 AltGr 의 가짜 LCtrl 이 켠 것이므로 걷어낸다.
|
||||
*/
|
||||
private _keyboardEventKey(e: UiohookKeyboardEvent, vk: number | null): string | null {
|
||||
private _keyboardEventKey(
|
||||
e: UiohookKeyboardEvent,
|
||||
vk: number | null,
|
||||
isAltGr = false
|
||||
): string | null {
|
||||
if (vk === null) return null
|
||||
return bindingKey({
|
||||
device: 'keyboard',
|
||||
code: vk,
|
||||
ctrl: e.ctrlKey,
|
||||
ctrl: e.ctrlKey && !isAltGr,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
meta: e.metaKey
|
||||
|
|
@ -525,10 +539,18 @@ class KeyBindingService extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 다운. 바인딩되지 않은 키도 상태 머신에 알린다(noteKeyDown) — AltGr+Q('@') 처럼
|
||||
* 문자 키가 이어지면 보류 중인 AltGr 트리거를 취소해야 하기 때문이다.
|
||||
*/
|
||||
private _handleKeyDown(e: UiohookKeyboardEvent): void {
|
||||
const key = this._keyboardEventKey(e, uiohookCodeToVk(e.keycode))
|
||||
const vk = uiohookCodeToVk(e.keycode)
|
||||
// 판별기는 플랫폼과 무관하게 모든 눌림을 관측해 상태를 일관되게 유지한다.
|
||||
const isAltGr = this._altGr.keyDown(vk, e.time) && process.platform === 'win32'
|
||||
this._machine.noteKeyDown(vk)
|
||||
const key = this._keyboardEventKey(e, vk, isAltGr)
|
||||
if (key === null) return
|
||||
this._machine.keyDown(key)
|
||||
this._machine.keyDown(key, { altGr: isAltGr })
|
||||
}
|
||||
|
||||
private _handleMouseDown(e: UiohookMouseEvent): void {
|
||||
|
|
@ -543,6 +565,7 @@ class KeyBindingService extends EventEmitter {
|
|||
*/
|
||||
private _handleKeyUp(e: UiohookKeyboardEvent): void {
|
||||
const vk = uiohookCodeToVk(e.keycode)
|
||||
this._altGr.keyUp(vk)
|
||||
this._machine.keyUp(this._keyboardEventKey(e, vk), vk)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { resolveSystemPrompt } from './llm-prompts'
|
|||
import { getBundledOllamaPath } from '../utils/paths'
|
||||
import { normalizeLoopbackUrl } from '../utils/loopback'
|
||||
import { readNdjsonLines } from '../utils/ndjson-reader'
|
||||
import { ModelResidencyLedger, type ModelResidencyPort } from './llm/model-residency'
|
||||
import {
|
||||
toChatRequest,
|
||||
toRoleMessages,
|
||||
|
|
@ -160,6 +161,8 @@ function stripReasoningBlocks(text: string): string {
|
|||
|
||||
class LocalLLMService extends EventEmitter {
|
||||
private _state = LLMState.Unavailable
|
||||
/** 모든 로컬 요청의 keep_alive 로 추정한 모델 상주 시각 — 제안 온기 판정이 읽는다 */
|
||||
private readonly _residency = new ModelResidencyLedger()
|
||||
/** 모델별 진행 중 pull — 중복 요청은 기존 promise에 합류 */
|
||||
private _pullInFlight = new Map<string, Promise<void>>()
|
||||
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
|
@ -447,6 +450,7 @@ class LocalLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
const data = (await response.json()) as OllamaGenerateResponse
|
||||
this._residency.noteRequest(model, options?.keepAlive, Date.now())
|
||||
|
||||
const result: GenerateResult = {
|
||||
text: data.response,
|
||||
|
|
@ -558,6 +562,8 @@ class LocalLLMService extends EventEmitter {
|
|||
request.abortCause ??= 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
// 서버가 응답을 시작했다면 모델이 올라왔고 이 요청의 keep_alive 가 만료 시각을 정했다.
|
||||
if (reader) this._residency.noteRequest(model, options?.keepAlive, Date.now())
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -900,10 +906,16 @@ class LocalLLMService extends EventEmitter {
|
|||
request.abortCause ??= 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
if (reader) this._residency.noteRequest(model, options?.keepAlive, Date.now())
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 모델 상주 추정 포트 (Ollama 는 가장 최근 요청의 keep_alive 로 만료를 다시 정한다). */
|
||||
get residency(): ModelResidencyPort {
|
||||
return this._residency
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposed = true
|
||||
this.stopPolling()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
|
|||
import { getRuntimeProvisioner } from './RuntimeProvisioner'
|
||||
import { SidecarSupervisor, type SidecarSupervisorEvents } from './stt/SidecarSupervisor'
|
||||
import { isSidecarLaunchAvailableOffline, resolveSidecarLaunch } from './stt/sidecarLaunch'
|
||||
import { transcriptionTimeoutMs } from './stt/audio-utils'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
STTModel,
|
||||
|
|
@ -120,6 +121,11 @@ export interface LocalSTTEvents {
|
|||
// ── 상수 ──────────────────────────────────────────────────
|
||||
|
||||
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
|
||||
/**
|
||||
* 최종 전사는 오디오 1초당 이만큼 더 기다린다 — CPU int8 large-v3(beam 5 + 온도 폴백)는 실시간보다
|
||||
* 느릴 수 있다. 짧은 녹음은 SIDECAR_REQUEST_TIMEOUT_MS 를 하한으로 쓴다.
|
||||
*/
|
||||
const SIDECAR_TRANSCRIBE_MS_PER_AUDIO_SECOND = 3000
|
||||
/** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */
|
||||
const SIDECAR_PARTIAL_TIMEOUT_MS = 15000
|
||||
|
||||
|
|
@ -378,6 +384,16 @@ class LocalSTTService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 모델을 내려받기 없이 올릴 수 있는가 (models-dir 에 있거나 이미 올라가 있다).
|
||||
* 설치하지 않은 모델을 /load 하면 사이드카가 HF 에서 몇 GB 를 내려받으며 기존 모델을 먼저 내린다 —
|
||||
* 모델 선택(SET_MODEL) 같은 경로는 이 검사를 통과한 모델만 initialize 해야 한다.
|
||||
*/
|
||||
isModelInstalled(modelId: string): boolean {
|
||||
if (!modelId) return false
|
||||
return modelId === this._currentModelId || existsSync(join(getWhisperModelsDir(), modelId, 'model.bin'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 모델과 엔진을 내려받기 없이 쓸 수 있는지 확인한다 (requireInstalled 경로).
|
||||
* 모델이 없으면 사이드카 /load가 HF에서 암묵적으로 내려받고, 엔진이 없으면 런타임을 내려받는다 —
|
||||
|
|
@ -735,7 +751,12 @@ class LocalSTTService extends EventEmitter {
|
|||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(
|
||||
isPartial ? SIDECAR_PARTIAL_TIMEOUT_MS : SIDECAR_REQUEST_TIMEOUT_MS,
|
||||
isPartial
|
||||
? SIDECAR_PARTIAL_TIMEOUT_MS
|
||||
: transcriptionTimeoutMs(audioBuffer.byteLength, {
|
||||
floorMs: SIDECAR_REQUEST_TIMEOUT_MS,
|
||||
perAudioSecondMs: SIDECAR_TRANSCRIBE_MS_PER_AUDIO_SECOND,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ import { getDatabase } from '../db'
|
|||
import { onAccountScopeChanged } from './account-scope'
|
||||
import { getLlmGateway } from './llm/LlmGateway'
|
||||
import { MeetingRecordingSession, type RecordingSnapshot } from './meeting/MeetingRecordingSession'
|
||||
import {
|
||||
isRecoverableMeeting,
|
||||
syncStateRecordingLedger,
|
||||
type LocalRecordingLedger,
|
||||
} from './meeting/local-recording-ledger'
|
||||
import {
|
||||
createTranscriptStore,
|
||||
transcriptBase,
|
||||
type MeetingTranscriptStore,
|
||||
type TranscriptRevision,
|
||||
} from './meeting/transcript-revision-store'
|
||||
import {
|
||||
buildDiarizationChunkInput,
|
||||
diarizationContinuityHint,
|
||||
extractSpeakerLabels,
|
||||
lastLines,
|
||||
stripEchoedContext,
|
||||
} from './meeting/diarization-context'
|
||||
import { condenseTranscriptToBudget } from './meeting/transcript-condenser'
|
||||
import { selectTranscriptExcerpts } from '@d3ro/core/meeting-llm-input'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
|
|
@ -61,12 +79,18 @@ const logger = getLogger('MeetingModeService')
|
|||
const REWRITE_CHUNK_CHARS = 1500
|
||||
/** 조각 하나의 출력 토큰 상한 — 로컬(Ollama)·Premium 모두 4096 이하만 허용한다 */
|
||||
const REWRITE_CHUNK_MAX_TOKENS = 2048
|
||||
/** 화자 추정에서 다음 조각에 넘기는 앞 조각의 라벨된 마지막 줄 수 */
|
||||
const DIARIZATION_CONTEXT_LINES = 4
|
||||
/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */
|
||||
const MAX_CHAT_HISTORY_MESSAGES = 20
|
||||
/** 녹음 중 전사를 DB 에 남기는 주기 — 비정상 종료 시 잃는 양의 상한 */
|
||||
const TRANSCRIPT_CHECKPOINT_MS = 15_000
|
||||
/** 중단된 녹음(전사 없음)의 오류 메시지 — 저장용(UI 는 status 로 표시) */
|
||||
const INTERRUPTED_MESSAGE = 'Recording was interrupted before it was saved'
|
||||
/** 녹음 동안 인식이 계속 실패해 전사가 하나도 없다 */
|
||||
const TRANSCRIPTION_FAILED_MESSAGE = 'Transcription failed during recording'
|
||||
/** 자막 인식 실패를 렌더러에 다시 알리는 최소 간격 */
|
||||
const CAPTION_FAILURE_NOTICE_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 회의 기능이 쓰는 LLM 포트 — 백엔드 선택(local/online, 폴백)은 LlmGateway 한 곳이 정한다.
|
||||
|
|
@ -116,8 +140,16 @@ class MeetingModeService extends EventEmitter {
|
|||
/** 회의마다 진행 중인 채팅 — 한 회의의 취소가 다른 회의 채팅을 끊지 않게 */
|
||||
private _chatAbortControllers = new Map<string, AbortController>()
|
||||
private readonly _unsubscribeScope: () => void
|
||||
/** 회의별 자막 인식 실패 수 — 전사가 비었을 때 'completed' 대신 'error' 로 닫는 근거 */
|
||||
private readonly _captionFailures = new Map<string, number>()
|
||||
private _unsubscribeCaptionFailure: (() => void) | null = null
|
||||
private _lastCaptionFailureNoticeAt = 0
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
private readonly _recordingLedger: LocalRecordingLedger = syncStateRecordingLedger,
|
||||
/** 전사 수정본 쓰기의 단일 관문 — 기준본 확인(compare-and-set)과 동기화 예약을 맡는다 */
|
||||
private readonly _transcripts: MeetingTranscriptStore = createSqliteMeetingTranscriptStore(),
|
||||
) {
|
||||
super()
|
||||
// 이 서비스가 내는 'error' 는 알림용이다 — 듣는 쪽이 없어도 throw 되지 않게 기본 수신자를 둔다
|
||||
// (EventEmitter 는 리스너 없는 'error' 를 던져 후처리 catch 를 깨뜨렸다).
|
||||
|
|
@ -255,6 +287,8 @@ class MeetingModeService extends EventEmitter {
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
// 이 기기에서 시작한 녹음 — 앱이 끊기면 다음 시작 때 이것만 복구한다(다른 기기 회의는 건드리지 않음).
|
||||
this._markLocalRecording(sessionId)
|
||||
|
||||
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
|
|
@ -306,6 +340,7 @@ class MeetingModeService extends EventEmitter {
|
|||
session.detach()
|
||||
if (this._current === session) this._current = null
|
||||
this._meetingModeActive = false
|
||||
this._clearLocalRecording(sessionId)
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
status: 'error',
|
||||
|
|
@ -318,6 +353,9 @@ class MeetingModeService extends EventEmitter {
|
|||
throw err
|
||||
}
|
||||
|
||||
// 인식이 계속 실패하면(사이드카 다운·모델 로드 실패) 회의가 조용히 빈 전사로 끝나지 않게 알린다.
|
||||
this._watchCaptionFailures(captionService, sessionId)
|
||||
|
||||
// 오디오 레벨 모니터링 + WAV 저장용 버퍼 (Phase 15.5-2)
|
||||
session.attachAudio()
|
||||
this._audioLevelTimer = setInterval(() => {
|
||||
|
|
@ -439,6 +477,7 @@ class MeetingModeService extends EventEmitter {
|
|||
/**
|
||||
* 이 DB 에 'recording'/'processing' 으로 남은 회의(앱 강제 종료·정전 등)를 닫는다.
|
||||
* 저장된 전사가 있으면 'completed', 없으면 'error'. 진행 중인 녹음은 건드리지 않는다.
|
||||
* 이 기기에서 시작한 녹음만 닫는다 — 동기화로 들어온 폰의 진행 중인 회의는 폰이 끝낸다.
|
||||
* @returns 정리한 회의 수
|
||||
*/
|
||||
recoverInterruptedSessions(): number {
|
||||
|
|
@ -453,6 +492,7 @@ class MeetingModeService extends EventEmitter {
|
|||
let recovered = 0
|
||||
for (const row of rows) {
|
||||
if (row.id === liveId) continue
|
||||
if (!isRecoverableMeeting(row, this._recordingLedger)) continue
|
||||
const now = Date.now()
|
||||
const endedAt = row.endedAt ?? row.updatedAt ?? now
|
||||
const hasTranscript = !!row.rawTranscript && row.rawTranscript.trim().length > 0
|
||||
|
|
@ -464,6 +504,7 @@ class MeetingModeService extends EventEmitter {
|
|||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, row.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', row.id)
|
||||
this._clearLocalRecording(row.id)
|
||||
recovered++
|
||||
}
|
||||
if (recovered > 0) logger.warn(`중단된 회의 ${recovered}건을 정리했다`)
|
||||
|
|
@ -550,17 +591,22 @@ class MeetingModeService extends EventEmitter {
|
|||
const db = getDatabase()
|
||||
const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt)
|
||||
const hasTranscript = rawTranscript.trim().length > 0
|
||||
const status = options.interrupted && !hasTranscript ? 'error' : 'completed'
|
||||
// 전사가 비었는데 녹음 동안 인식이 실패했다면 '완료'가 아니다 — 사용자가 끝난 뒤에야 빈 회의를 발견했다.
|
||||
const transcriptionFailed = !hasTranscript && (this._captionFailures.get(snapshot.id) ?? 0) > 0
|
||||
const status = !hasTranscript && (options.interrupted || transcriptionFailed) ? 'error' : 'completed'
|
||||
const errorMessage = options.interrupted ? INTERRUPTED_MESSAGE : TRANSCRIPTION_FAILED_MESSAGE
|
||||
db.update(meetingSessions).set({
|
||||
status,
|
||||
rawTranscript,
|
||||
endedAt: snapshot.endedAt,
|
||||
durationMs: snapshot.durationMs,
|
||||
sttModel: configGet('sttModelId') as string | undefined,
|
||||
...(status === 'error' ? { errorMessage: INTERRUPTED_MESSAGE } : {}),
|
||||
...(status === 'error' ? { errorMessage } : {}),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, snapshot.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', snapshot.id)
|
||||
this._captionFailures.delete(snapshot.id)
|
||||
this._clearLocalRecording(snapshot.id)
|
||||
|
||||
if (snapshot.pcm.length > 0) {
|
||||
try {
|
||||
|
|
@ -593,7 +639,49 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private _watchCaptionFailures(
|
||||
captionService: { on(event: 'failure', listener: (error: D3ROError) => void): unknown; off(event: 'failure', listener: (error: D3ROError) => void): unknown },
|
||||
sessionId: string,
|
||||
): void {
|
||||
this._unsubscribeCaptionFailure?.()
|
||||
this._captionFailures.set(sessionId, 0)
|
||||
this._lastCaptionFailureNoticeAt = 0
|
||||
const listener = (error: D3ROError): void => {
|
||||
if (this._current?.id !== sessionId) return
|
||||
this._captionFailures.set(sessionId, (this._captionFailures.get(sessionId) ?? 0) + 1)
|
||||
const now = Date.now()
|
||||
if (now - this._lastCaptionFailureNoticeAt < CAPTION_FAILURE_NOTICE_INTERVAL_MS) return
|
||||
this._lastCaptionFailureNoticeAt = now
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
|
||||
code: error.code ?? ErrorCode.MeetingProcessingFailed,
|
||||
message: error.message,
|
||||
})
|
||||
}
|
||||
captionService.on('failure', listener)
|
||||
this._unsubscribeCaptionFailure = () => {
|
||||
captionService.off('failure', listener)
|
||||
}
|
||||
}
|
||||
|
||||
private _markLocalRecording(sessionId: string): void {
|
||||
try {
|
||||
this._recordingLedger.mark(sessionId)
|
||||
} catch (err) {
|
||||
logger.warn(`로컬 녹음 표식 기록 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _clearLocalRecording(sessionId: string): void {
|
||||
try {
|
||||
this._recordingLedger.clear(sessionId)
|
||||
} catch (err) {
|
||||
logger.warn(`로컬 녹음 표식 정리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _stopTimers(): void {
|
||||
this._unsubscribeCaptionFailure?.()
|
||||
this._unsubscribeCaptionFailure = null
|
||||
if (this._audioLevelTimer) {
|
||||
clearInterval(this._audioLevelTimer)
|
||||
this._audioLevelTimer = null
|
||||
|
|
@ -831,19 +919,27 @@ class MeetingModeService extends EventEmitter {
|
|||
// ── Phase 14.5: 전사 수정 ──
|
||||
|
||||
updateTranscript(sessionId: string, editedTranscript: string): void {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
if (!row) {
|
||||
// 사용자의 수동 저장(실시간 세그먼트 반영 포함)은 기준본 확인 없이 쓴다 — 렌더러 계약은 그대로다.
|
||||
if (this._transcripts.writeEdited(sessionId, editedTranscript) === 'not-found') {
|
||||
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
||||
}
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 결과(다듬기·화자 구분)를 읽었던 기준본 위에서만 쓴다. 그 사이 사용자가 전사를 고쳤으면
|
||||
* 덮어쓰지 않고 실패시킨다 — 예전엔 마지막 쓰기가 이겨 수동 편집이 조용히 사라졌다.
|
||||
*/
|
||||
private _writeRewrite(sessionId: string, text: string, base: TranscriptRevision, failureCode: ErrorCode): void {
|
||||
const result = this._transcripts.writeEdited(sessionId, text, base.revision)
|
||||
if (result === 'not-found') {
|
||||
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
}
|
||||
if (result === 'conflict') {
|
||||
throw new D3ROError(failureCode, '작업하는 동안 전사가 수정되어 결과를 저장하지 않았습니다. 다시 시도해 주세요.')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 14.5: 문서 생성 ──
|
||||
|
||||
async generateDocument(params: MeetingGenerateDocParams): Promise<MeetingDocument> {
|
||||
|
|
@ -1181,26 +1277,22 @@ class MeetingModeService extends EventEmitter {
|
|||
// ── Phase 15: Auto Polish ──
|
||||
|
||||
async polishTranscript(sessionId: string): Promise<string> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
const base = this._transcripts.read(sessionId)
|
||||
if (!base) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
|
||||
const transcript = row.rawTranscript
|
||||
// 기준본은 수정본(사용자 편집·화자 라벨) 우선 — 원문으로 다듬으면 편집과 라벨이 통째로 사라졌다.
|
||||
const transcript = transcriptBase(base)
|
||||
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
|
||||
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
const polished = await this._rewriteTranscriptInChunks(
|
||||
llm,
|
||||
transcript,
|
||||
'다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
||||
'다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]과 화자 표기([화자 N] 등)는 그대로 유지하세요.',
|
||||
ErrorCode.MeetingPolishFailed,
|
||||
)
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: polished,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
this._writeRewrite(sessionId, polished, base, ErrorCode.MeetingPolishFailed)
|
||||
|
||||
logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
|
||||
return polished
|
||||
|
|
@ -1217,26 +1309,34 @@ class MeetingModeService extends EventEmitter {
|
|||
transcript: string,
|
||||
systemPrompt: string,
|
||||
failureCode: ErrorCode,
|
||||
options: { carrySpeakerContext?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const chunks = chunkTranscriptByLines(transcript, REWRITE_CHUNK_CHARS)
|
||||
if (chunks.length === 0) {
|
||||
throw new D3ROError(failureCode, '전사 텍스트가 없습니다')
|
||||
}
|
||||
const outputs: string[] = []
|
||||
let previousTail: string[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]
|
||||
const result = await llm.generate(chunk, {
|
||||
systemPrompt,
|
||||
// 화자 추정은 조각 사이에 화자 번호를 이어 준다 — 앞 조각의 라벨된 끝부분과 지금까지의 화자 목록.
|
||||
const carry = options.carrySpeakerContext === true
|
||||
const input = carry ? buildDiarizationChunkInput(chunk, previousTail) : chunk
|
||||
const prompt = carry ? systemPrompt + diarizationContinuityHint(extractSpeakerLabels(outputs.join('\n'))) : systemPrompt
|
||||
const result = await llm.generate(input, {
|
||||
systemPrompt: prompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: REWRITE_CHUNK_MAX_TOKENS,
|
||||
})
|
||||
if (looksTruncatedRewrite(chunk, result.text)) {
|
||||
const text = carry ? stripEchoedContext(result.text, previousTail) : result.text.trim()
|
||||
if (looksTruncatedRewrite(chunk, text)) {
|
||||
throw new D3ROError(
|
||||
failureCode,
|
||||
`LLM 결과가 잘린 것으로 보여 저장하지 않았습니다 (조각 ${i + 1}/${chunks.length})`,
|
||||
)
|
||||
}
|
||||
outputs.push(result.text.trim())
|
||||
outputs.push(text.trim())
|
||||
if (carry) previousTail = lastLines(text, DIARIZATION_CONTEXT_LINES)
|
||||
}
|
||||
return outputs.join('\n')
|
||||
}
|
||||
|
|
@ -1360,10 +1460,9 @@ ${excerpt.text}`
|
|||
// ── Phase 15.5: 화자 구분 ──
|
||||
|
||||
async diarizeSession(sessionId: string, numSpeakers?: number): Promise<void> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
const row = this._transcripts.read(sessionId)
|
||||
if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
if (!row.rawTranscript) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다')
|
||||
if (!row.raw) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다')
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
|
||||
|
||||
|
|
@ -1410,7 +1509,7 @@ ${excerpt.text}`
|
|||
|
||||
// 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다.
|
||||
// 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다.
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
const transcript = transcriptBase(row) ?? ''
|
||||
const labeledLines = applySpeakerLabels(
|
||||
parseTranscriptLines(transcript),
|
||||
diarResult.segments,
|
||||
|
|
@ -1419,11 +1518,7 @@ ${excerpt.text}`
|
|||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: formatTranscriptLines(labeledLines),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
this._writeRewrite(sessionId, formatTranscriptLines(labeledLines), row, ErrorCode.DiarizationFailed)
|
||||
|
||||
logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`)
|
||||
} catch (err) {
|
||||
|
|
@ -1442,14 +1537,14 @@ ${excerpt.text}`
|
|||
|
||||
private async _diarizeLLMFallback(
|
||||
sessionId: string,
|
||||
row: { rawTranscript: string | null; editedTranscript: string | null },
|
||||
row: TranscriptRevision,
|
||||
numSpeakers?: number,
|
||||
): Promise<void> {
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 30 })
|
||||
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
const transcript = transcriptBase(row) ?? ''
|
||||
const speakerHint = numSpeakers && numSpeakers > 0
|
||||
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
|
||||
: '화자 수는 문맥에서 추정하세요.'
|
||||
|
|
@ -1464,16 +1559,12 @@ ${speakerHint}
|
|||
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
|
||||
원문의 타임스탬프와 내용은 변경하지 마세요.`,
|
||||
ErrorCode.DiarizationFailed,
|
||||
{ carrySpeakerContext: true },
|
||||
)
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
|
||||
|
||||
const db = getDatabase()
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: labeled,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
this._writeRewrite(sessionId, labeled, row, ErrorCode.DiarizationFailed)
|
||||
}
|
||||
|
||||
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
||||
|
|
@ -1488,6 +1579,26 @@ ${speakerHint}
|
|||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
/** 기본 전사 저장소 — meeting_sessions 행을 읽고 쓰며, 쓴 뒤 동기화를 예약한다. */
|
||||
function createSqliteMeetingTranscriptStore(): MeetingTranscriptStore {
|
||||
return createTranscriptStore({
|
||||
readRow: (sessionId) =>
|
||||
getDatabase()
|
||||
.select({ rawTranscript: meetingSessions.rawTranscript, editedTranscript: meetingSessions.editedTranscript })
|
||||
.from(meetingSessions)
|
||||
.where(eq(meetingSessions.id, sessionId))
|
||||
.get(),
|
||||
writeEdited: (sessionId, editedTranscript, now) => {
|
||||
getDatabase()
|
||||
.update(meetingSessions)
|
||||
.set({ editedTranscript, updatedAt: now })
|
||||
.where(eq(meetingSessions.id, sessionId))
|
||||
.run()
|
||||
},
|
||||
onWritten: (sessionId) => getCloudSyncService().pushOne('meetings', sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
let instance: MeetingModeService | null = null
|
||||
|
||||
export function getMeetingModeService(): MeetingModeService {
|
||||
|
|
|
|||
|
|
@ -111,13 +111,14 @@ class MeetingSummaryService extends EventEmitter {
|
|||
}
|
||||
|
||||
// DB에 요약 저장
|
||||
db.update(history)
|
||||
const saved = db.update(history)
|
||||
.set({ summaryText: rawMarkdown, updatedAt: Date.now() })
|
||||
.where(eq(history.id, historyId))
|
||||
.run()
|
||||
|
||||
// Phase 3.3: 요약 저장 후 자동 push (fire-and-forget)
|
||||
void getCloudSyncService().pushOne('history', historyId)
|
||||
// Phase 3.3: 요약 저장 후 자동 push (fire-and-forget).
|
||||
// 요약하는 사이 기록이 지워졌으면 올리지 않는다 — 대기 중인 원격 삭제를 upsert 로 덮는다.
|
||||
if (saved.changes > 0) void getCloudSyncService().pushOne('history', historyId)
|
||||
|
||||
this._sendProgress(historyId, 'done')
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_READY, summaryResult)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { cloudSyncCredentials, type CloudCredentials } from './cloud/cloud-credentials'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LLMAction } from '@d3ro/core/types'
|
||||
import { resolveSystemPrompt } from './llm-prompts'
|
||||
|
|
@ -191,13 +191,17 @@ class PremiumLLMService extends EventEmitter {
|
|||
private _disposed = false
|
||||
private _lastQuota: QuotaUsageSnapshot | null = null
|
||||
|
||||
/** 계정 자격 증명 포트 — CloudSyncService 전체가 아닌 필요한 면만 의존한다(ISP/DIP). */
|
||||
constructor(private readonly _credentials: CloudCredentials = cloudSyncCredentials) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능 여부 — Supabase URL + access token 모두 있어야 true.
|
||||
* (LLMRouter가 local/premium 분기 시 호출)
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
const cloud = getCloudSyncService()
|
||||
return cloud.isEnabled() && cloud.isAuthenticated()
|
||||
return this._credentials.hasCredentials()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,8 +220,7 @@ class PremiumLLMService extends EventEmitter {
|
|||
if (this._disposed) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'PremiumLLMService disposed')
|
||||
}
|
||||
const cloud = getCloudSyncService()
|
||||
if (!cloud.isEnabled() || !cloud.isAuthenticated()) {
|
||||
if (!this._credentials.hasCredentials()) {
|
||||
this.emit('upgrade-required', { reason: 'auth_required' })
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMServerUnreachable,
|
||||
|
|
@ -292,8 +295,7 @@ class PremiumLLMService extends EventEmitter {
|
|||
let completed = false
|
||||
|
||||
try {
|
||||
const cloud = getCloudSyncService()
|
||||
const { stream, error } = await cloud.invokeFunctionStream(
|
||||
const { stream, error } = await this._credentials.invokeStream(
|
||||
'llm-proxy',
|
||||
body as unknown as Record<string, unknown>,
|
||||
call.controller.signal,
|
||||
|
|
@ -403,13 +405,11 @@ class PremiumLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise<ClaudeMessageResponse> {
|
||||
const cloud = getCloudSyncService()
|
||||
|
||||
// Supabase JS 클라이언트의 functions.invoke() 사용 — auth 헤더를 올바르게 처리.
|
||||
// raw fetch + Authorization: Bearer 방식은 Supabase gateway가 401로 거부.
|
||||
const { data, error } = signal
|
||||
? await cloud.invokeFunction('llm-proxy', body as unknown as Record<string, unknown>, { signal })
|
||||
: await cloud.invokeFunction('llm-proxy', body as unknown as Record<string, unknown>)
|
||||
? await this._credentials.invoke('llm-proxy', body as unknown as Record<string, unknown>, { signal })
|
||||
: await this._credentials.invoke('llm-proxy', body as unknown as Record<string, unknown>)
|
||||
|
||||
if (error) {
|
||||
const msg = error.message ?? 'Edge Function error'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// - rag/embedding-port.ts: 임베딩 포트 + Ollama 어댑터(모델 존재 확인 포함)
|
||||
// - rag/chunk-store.ts : 문서·청크 저장소 포트 + SQLite 구현
|
||||
// - rag/retrieval.ts : 유사도 순위·답변 프롬프트 (순수 함수)
|
||||
// - rag/remote-document.ts: 동기화로 받은 문서의 삽입/교체/무시 판정 (순수 함수)
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
import { OllamaEmbeddingAdapter, type EmbeddingPort } from './rag/embedding-port'
|
||||
import { SqliteChunkStore, type ChunkStore } from './rag/chunk-store'
|
||||
import { buildAnswerSystemPrompt, rankChunks } from './rag/retrieval'
|
||||
import { planRemoteDocument, type RemoteKnowledgeDocument } from './rag/remote-document'
|
||||
|
||||
const logger = getLogger('RAGService')
|
||||
|
||||
|
|
@ -219,31 +221,32 @@ export class RAGService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* 동기화: 다른 기기(모바일·웹)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다.
|
||||
* 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 filePath는 비워 둔다.
|
||||
* 동기화: 다른 기기(모바일·웹·데스크톱)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다.
|
||||
* 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 새 문서의 filePath는 비워 둔다.
|
||||
* 이미 있는 문서라도 원문이 달라졌으면(다른 기기에서 재색인) 청크를 바꾸고 다시 임베딩한다.
|
||||
* 반영했으면 true, 바뀐 게 없으면 false.
|
||||
*/
|
||||
applyRemoteDocument(doc: {
|
||||
id: string
|
||||
fileName: string
|
||||
fileType: RAGDocument['fileType']
|
||||
chunks: string[]
|
||||
addedAt: number
|
||||
}): boolean {
|
||||
applyRemoteDocument(doc: RemoteKnowledgeDocument): boolean {
|
||||
const { store } = this.deps
|
||||
if (store.hasDocument(doc.id)) return false
|
||||
const chunks = doc.chunks.filter((c) => c.trim().length > 0)
|
||||
if (chunks.length === 0) return false
|
||||
store.insertDocument({
|
||||
id: doc.id,
|
||||
fileName: doc.fileName,
|
||||
filePath: '',
|
||||
fileType: doc.fileType,
|
||||
chunkCount: chunks.length,
|
||||
indexed: false,
|
||||
indexedAt: null,
|
||||
addedAt: doc.addedAt,
|
||||
})
|
||||
store.replaceChunks(doc.id, chunks)
|
||||
const local = store.hasDocument(doc.id) ? this.getStoredChunks(doc.id) : null
|
||||
const plan = planRemoteDocument(local, doc.chunks)
|
||||
if (plan.kind === 'skip') return false
|
||||
if (plan.kind === 'insert') {
|
||||
store.insertDocument({
|
||||
id: doc.id,
|
||||
fileName: doc.fileName,
|
||||
filePath: '',
|
||||
fileType: doc.fileType,
|
||||
chunkCount: plan.chunks.length,
|
||||
indexed: false,
|
||||
indexedAt: null,
|
||||
addedAt: doc.addedAt,
|
||||
})
|
||||
} else {
|
||||
// 옛 임베딩이 남아 검색에 걸리지 않도록 색인 안 됨으로 되돌린 뒤 청크를 바꾼다.
|
||||
store.updateDocument(doc.id, { chunkCount: plan.chunks.length, indexed: false, indexedAt: null })
|
||||
}
|
||||
store.replaceChunks(doc.id, plan.chunks)
|
||||
this._embedStoredChunks(doc.id, doc.fileName).catch((err) => {
|
||||
logger.warn(`Synced document ${doc.fileName} is stored but not embedded yet:`, err)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { RUNTIME_FEED_URL, RUNTIME_INDEX_FILENAME } from '../update-feed'
|
|||
import {
|
||||
RUNTIME_COMPONENTS,
|
||||
RUNTIME_MIN_VERSION,
|
||||
isRuntimeIndexRejected,
|
||||
isRuntimeVersionSatisfied,
|
||||
parseRuntimeIndex,
|
||||
type RuntimeComponent,
|
||||
|
|
@ -77,6 +78,8 @@ export interface RuntimeProvisionerDeps {
|
|||
fetchImpl: RuntimeFetch
|
||||
feedUrl: string
|
||||
platform: NodeJS.Platform
|
||||
/** 런타임 번들의 아키텍처 확인용 (인덱스의 arch 와 비교) */
|
||||
arch: string
|
||||
stallTimeoutMs: number
|
||||
partAttempts: number
|
||||
}
|
||||
|
|
@ -94,6 +97,7 @@ function defaultDeps(): RuntimeProvisionerDeps {
|
|||
fetchImpl: defaultFetch,
|
||||
feedUrl: RUNTIME_FEED_URL,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS,
|
||||
partAttempts: DEFAULT_PART_DOWNLOAD_ATTEMPTS,
|
||||
}
|
||||
|
|
@ -197,13 +201,35 @@ export class RuntimeProvisioner extends EventEmitter {
|
|||
return existing
|
||||
}
|
||||
|
||||
const task = this._install(component).finally(() => {
|
||||
this._inFlight.delete(component)
|
||||
})
|
||||
const task = this._install(component)
|
||||
.catch((err: unknown) => this._fallBackToInstalled(component, err, options))
|
||||
.finally(() => {
|
||||
this._inFlight.delete(component)
|
||||
})
|
||||
this._inFlight.set(component, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* feed 가 이 앱의 최소 버전보다 낮으면(런타임 게시가 앱 게시보다 늦은 경우) 이미 설치된
|
||||
* 엔진을 계속 쓴다. 예전에는 여기서 예외를 올려, 멀쩡히 돌던 엔진까지 버리고 받아쓰기와
|
||||
* 로컬 전사가 feed 가 따라올 때까지 전부 멈췄다. 새 API 가 필요한 기능만 실패하는 편이 낫다.
|
||||
* 사용자가 명시적으로 "다시 설치"(force)한 경우에는 실패를 그대로 알린다.
|
||||
*/
|
||||
private _fallBackToInstalled(
|
||||
component: RuntimeComponent,
|
||||
err: unknown,
|
||||
options: EnsureRuntimeOptions,
|
||||
): string {
|
||||
if (!options.force && isRuntimeIndexRejected(err, 'feed-outdated') && this.isInstalled(component)) {
|
||||
logger.warn(
|
||||
`런타임 feed가 최소 버전보다 낮아 설치된 ${component} 엔진을 계속 사용합니다 — ${err.message}`,
|
||||
)
|
||||
return this.binaryPath(component)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
private async _install(component: RuntimeComponent): Promise<string> {
|
||||
const started = Date.now()
|
||||
logger.info(`런타임 설치 시작: ${component}`)
|
||||
|
|
@ -261,7 +287,10 @@ export class RuntimeProvisioner extends EventEmitter {
|
|||
`런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`,
|
||||
)
|
||||
}
|
||||
return parseRuntimeIndex(await response.json(), component)
|
||||
return parseRuntimeIndex(await response.json(), component, RUNTIME_MIN_VERSION[component], {
|
||||
platform: this._deps.platform,
|
||||
arch: this._deps.arch,
|
||||
})
|
||||
}
|
||||
|
||||
private async _downloadParts(
|
||||
|
|
|
|||
|
|
@ -40,22 +40,30 @@ export interface SuggestionRecordInput {
|
|||
}
|
||||
|
||||
export interface SuggestionRepository {
|
||||
/** 제안 후보 1건을 기록한다. */
|
||||
record(input: SuggestionRecordInput): void
|
||||
/** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다. */
|
||||
/**
|
||||
* 제안 세션 1건을 기록한다(첫 후보 · 첫 요청 지연 · 후보 수). 행 id 를 돌려준다(실패하면 null).
|
||||
* 한 세션은 한 행이다 — 채우기 후보를 따로 넣으면 평균 지연이 0 으로 희석되고 수락률이 1/N 로 깎인다.
|
||||
*/
|
||||
record(input: SuggestionRecordInput): string | null | void
|
||||
/** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다 (세션 id 를 모를 때의 호환 경로). */
|
||||
markAccepted(text: string): void
|
||||
/** 세션 행의 후보 수를 갱신한다 (채우기 루프가 후보를 더할 때). */
|
||||
updateSessionCandidates?(id: string, candidateCount: number): void
|
||||
/** 이 세션 행을 수락으로 표시하고, 실제로 고른 후보 원문을 남긴다. */
|
||||
markSessionAccepted?(id: string, acceptedText: string): void
|
||||
/** 최근 이력 (limit 는 1~200 으로 클램프). */
|
||||
list(limit: number): SuggestionHistoryEntry[]
|
||||
}
|
||||
|
||||
export function createSqliteSuggestionRepository(): SuggestionRepository {
|
||||
return {
|
||||
record(input: SuggestionRecordInput): void {
|
||||
record(input: SuggestionRecordInput): string | null {
|
||||
const id = crypto.randomUUID()
|
||||
try {
|
||||
getDatabase()
|
||||
.insert(suggestions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
id,
|
||||
appName: input.appName,
|
||||
prefixText: input.prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS),
|
||||
suggestionText: input.text,
|
||||
|
|
@ -66,8 +74,30 @@ export function createSqliteSuggestionRepository(): SuggestionRepository {
|
|||
createdAt: Date.now()
|
||||
})
|
||||
.run()
|
||||
return id
|
||||
} catch (error) {
|
||||
logger.warn(`제안 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateSessionCandidates(id: string, candidateCount: number): void {
|
||||
try {
|
||||
getDatabase().update(suggestions).set({ candidateCount }).where(eq(suggestions.id, id)).run()
|
||||
} catch (error) {
|
||||
logger.warn(`제안 후보 수 갱신 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
markSessionAccepted(id: string, acceptedText: string): void {
|
||||
try {
|
||||
getDatabase()
|
||||
.update(suggestions)
|
||||
.set({ accepted: true, suggestionText: acceptedText })
|
||||
.where(eq(suggestions.id, id))
|
||||
.run()
|
||||
} catch (error) {
|
||||
logger.warn(`수락 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ export function createDefaultSuggestionServiceDeps(): SuggestionServiceDeps {
|
|||
return {
|
||||
repository: createSqliteSuggestionRepository(),
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
// 상주 추정은 로컬 LLM 의 모든 요청(받아쓰기·음성 대화 포함)을 본다.
|
||||
warmth: new ModelWarmTracker({ residentUntil: (model) => getLocalLLMService().residency?.residentUntil(model) ?? null }),
|
||||
foreground: {
|
||||
currentWindowHandle: () => getForegroundWindowInfo()?.hwnd ?? null
|
||||
},
|
||||
|
|
@ -186,6 +187,8 @@ export class SuggestionService extends EventEmitter {
|
|||
private _provenance: SuggestionProvenance | null = null
|
||||
/** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */
|
||||
private _windowHandle: number | null = null
|
||||
/** 이 세션의 이력 행 id — 한 세션은 한 행이다(채우기 후보·수락은 이 행을 갱신한다). */
|
||||
private _sessionRecordId: string | null = null
|
||||
|
||||
/** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */
|
||||
private readonly _budget: SuggestionBudget
|
||||
|
|
@ -350,7 +353,24 @@ export class SuggestionService extends EventEmitter {
|
|||
setEnabled(enabled: boolean): void {
|
||||
configSet('suggestionEnabled', enabled)
|
||||
if (!enabled) this.dismiss('disabled')
|
||||
else void this.warmUp()
|
||||
// 입력 맥락(수집 동의)이 없으면 제안이 뜰 수 없다 — 모델만 데우지 않는다.
|
||||
else if (hasInputContextConsent()) void this.warmUp()
|
||||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 수집 동의·일시정지가 바뀌었다. 입력 맥락이 끊기면 떠 있는 세션·채우기 루프를 닫고
|
||||
* 붙잡고 있던 마지막 문맥을 버린다(동의를 철회한 뒤 수동 요청이 옛 접두로 생성하지 않게).
|
||||
* 다시 켜지면 제안이 켜져 있을 때만 모델을 데운다.
|
||||
*/
|
||||
handleInputConsentChanged(): void {
|
||||
if (!hasInputContextConsent()) {
|
||||
this.dismiss('disabled')
|
||||
this._lastContext = null
|
||||
this._lastRequestedPrefix = ''
|
||||
} else if (this.isEnabled()) {
|
||||
void this.warmUp()
|
||||
}
|
||||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
|
||||
|
|
@ -579,6 +599,7 @@ export class SuggestionService extends EventEmitter {
|
|||
if (this.isPresentationActive) this.dismiss(reason)
|
||||
return { ok: false, reason }
|
||||
}
|
||||
if (context.caretReliable === false) return { ok: false, reason: 'caret-unknown' }
|
||||
const prefix = normalizeRequestPrefix(context.prefix)
|
||||
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
|
||||
|
||||
|
|
@ -619,6 +640,7 @@ export class SuggestionService extends EventEmitter {
|
|||
editedSinceFocus: context.editedSinceFocus,
|
||||
typedRecently: context.typedRecently,
|
||||
prefix: context.prefix,
|
||||
caretReliable: context.caretReliable !== false,
|
||||
idleMs: context.idleMs,
|
||||
triggerDelayMs: config.triggerDelayMs,
|
||||
minPrefixChars: config.minPrefixChars,
|
||||
|
|
@ -696,7 +718,11 @@ export class SuggestionService extends EventEmitter {
|
|||
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
|
||||
const trimmedPrefix = normalizeSessionPrefix(prefix)
|
||||
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
|
||||
const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars)
|
||||
// 원문 뒤 공백(단어를 끝냈는가)은 세션 접두 정규화가 지운다 — 최신 원문에서 읽어 넘긴다.
|
||||
const rawPrefix = this._lastContext?.prefix ?? context.prefix
|
||||
const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars, {
|
||||
wordFinished: /\s$/u.test(rawPrefix)
|
||||
})
|
||||
if (entries.length === 0) return false
|
||||
|
||||
this._partialText = ''
|
||||
|
|
@ -795,6 +821,7 @@ export class SuggestionService extends EventEmitter {
|
|||
// 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고
|
||||
// "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다.
|
||||
this._candidates = []
|
||||
this._sessionRecordId = null
|
||||
this._activeIndex = 0
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
|
|
@ -904,6 +931,9 @@ export class SuggestionService extends EventEmitter {
|
|||
if (!timedOut) return
|
||||
|
||||
this._lastSkipReason = 'generation-failed'
|
||||
// warm 이라 믿고 보낸 요청이 시간 초과했다면 모델은 이미 내려갔다(다른 기능의 짧은 keep_alive 등)
|
||||
// — 다음 멈춤은 같은 콜드 요청을 되풀이하지 말고 워밍업("준비 중")부터 한다.
|
||||
this._warmth.noteCold(model)
|
||||
this._noteFailure(`timeout ${timeoutMs}ms`)
|
||||
if (
|
||||
this._publishLocalMemory(
|
||||
|
|
@ -1102,13 +1132,10 @@ export class SuggestionService extends EventEmitter {
|
|||
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._recordSuggestion({
|
||||
prefix,
|
||||
text: candidateText,
|
||||
model,
|
||||
latencyMs: 0,
|
||||
candidateCount: this._candidates.length
|
||||
})
|
||||
// 채우기 후보는 새 행이 아니다 — 세션 행의 후보 수만 늘린다(지연 0 행이 평균·수락률을 망쳤다).
|
||||
if (this._sessionRecordId) {
|
||||
this._repository.updateSessionCandidates?.(this._sessionRecordId, this._candidates.length)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
|
|
@ -1320,6 +1347,7 @@ export class SuggestionService extends EventEmitter {
|
|||
this._generatedForPrefix = ''
|
||||
this._targetTotal = 0
|
||||
this._provenance = null
|
||||
this._sessionRecordId = null
|
||||
this._lastSkipReason = reason
|
||||
this._abort?.abort()
|
||||
this._abort = null
|
||||
|
|
@ -1350,12 +1378,21 @@ export class SuggestionService extends EventEmitter {
|
|||
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
|
||||
return { ok: false, reason: 'already-visible' }
|
||||
}
|
||||
// 케어렛을 모르는 필드에서 문서 중간을 고친 뒤다 — 후보는 문서 끝의 이어 쓰기라
|
||||
// 실제 커서(문서 중간)에 붙이면 엉뚱한 곳에 문장이 들어간다.
|
||||
if (this._lastContext?.caretReliable === false) {
|
||||
logger.info('제안 수락 거부: 케어렛 위치를 알 수 없고 마지막 편집이 문서 끝이 아니다')
|
||||
this.dismiss('caret-unknown')
|
||||
this.emit('state-changed', this.getState())
|
||||
return { ok: false, reason: 'caret-unknown' }
|
||||
}
|
||||
// 후보는 정제 과정에서 앞 공백을 잃는다 — 커서 앞 원문의 마지막 글자를 보고 구분 공백을
|
||||
// 붙인다(joinSuggestion). 세션을 닫기 전에(dismiss 가 세션 접두를 지운다) 계산한다.
|
||||
const text = joinSuggestion(this._acceptPrefix(), candidate.text, candidate.joint ?? 'separate')
|
||||
const appName = this._appName
|
||||
const windowTitle = this._windowTitle
|
||||
const targetWindow = this._windowHandle
|
||||
const sessionRecordId = this._sessionRecordId
|
||||
|
||||
// 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다.
|
||||
this.dismiss('accepted')
|
||||
|
|
@ -1401,7 +1438,13 @@ export class SuggestionService extends EventEmitter {
|
|||
|
||||
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
|
||||
// 이력은 후보 원문으로 기록돼 있다 — 구분 공백이 붙은 삽입 문자열로는 찾지 못한다.
|
||||
this._repository.markAccepted(candidate.text)
|
||||
// 세션 행을 알면 그 행을 수락으로 표시한다 — 텍스트로 찾으면 로컬 기억의 2번째 이후 후보는
|
||||
// 행이 없어 사라지거나 같은 문구의 옛 행(다른 앱·다른 날)이 수락으로 잡혔다.
|
||||
if (sessionRecordId && this._repository.markSessionAccepted) {
|
||||
this._repository.markSessionAccepted(sessionRecordId, candidate.text)
|
||||
} else {
|
||||
this._repository.markAccepted(candidate.text)
|
||||
}
|
||||
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
|
||||
this._learning.recordAccepted(candidate.text, { appName, windowTitle })
|
||||
return { ok: true }
|
||||
|
|
@ -1455,7 +1498,8 @@ export class SuggestionService extends EventEmitter {
|
|||
learnTypedText: configGet('inputLearnTypedText') === true,
|
||||
appName: this._appName
|
||||
})
|
||||
this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName })
|
||||
const id = this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName })
|
||||
this._sessionRecordId = typeof id === 'string' ? id : null
|
||||
}
|
||||
|
||||
/** 테스트/진단 — 현재 앱이 제외 대상인지. */
|
||||
|
|
@ -1483,6 +1527,11 @@ function sanitizePartial(raw: string): string {
|
|||
return lines.slice(0, 2).join(' ').slice(0, SUGGESTION_MAX_OUTPUT_CHARS)
|
||||
}
|
||||
|
||||
/** 입력 텔레메트리가 맥락을 보낼 수 있는 상태인가 (수집 동의 + 일시정지 아님). */
|
||||
export function hasInputContextConsent(): boolean {
|
||||
return configGet('inputTelemetryEnabled') === true && configGet('inputTelemetryPaused') !== true
|
||||
}
|
||||
|
||||
let instance: SuggestionService | null = null
|
||||
|
||||
export function getSuggestionService(): SuggestionService {
|
||||
|
|
|
|||
|
|
@ -1,140 +1,38 @@
|
|||
// src/main/services/VoiceCommandService.ts
|
||||
// Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택.
|
||||
// electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공.
|
||||
// 규칙 저장/활성 상태는 VoiceCommandStorePort로, 매칭·기본값 정책은 voice-command-policy.ts로 분리.
|
||||
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import type {
|
||||
VoiceCommandRule,
|
||||
VoiceCommandKeyword,
|
||||
VoiceCommandMatch,
|
||||
KeywordMatchMode
|
||||
VoiceCommandMatch
|
||||
} from '@d3ro/core/types'
|
||||
import {
|
||||
buildDefaultRules,
|
||||
findRuleMatch,
|
||||
migrateLegacyDefaultRules
|
||||
} from './voice-command-policy'
|
||||
|
||||
const logger = getLogger('voice-command')
|
||||
|
||||
// ============================================================
|
||||
// 기본 키워드 (프리셋 명령어용)
|
||||
// 저장소 포트 (기본 어댑터 = electron-store/ConfigService)
|
||||
// ============================================================
|
||||
|
||||
interface DefaultKeywordEntry {
|
||||
instructionId: string
|
||||
keywords: VoiceCommandKeyword[]
|
||||
priority: number
|
||||
export interface VoiceCommandStorePort {
|
||||
loadRules(): VoiceCommandRule[] | undefined
|
||||
saveRules(rules: VoiceCommandRule[]): void
|
||||
loadEnabled(): boolean | undefined
|
||||
saveEnabled(enabled: boolean): void
|
||||
}
|
||||
|
||||
const DEFAULT_KEYWORDS: ReadonlyArray<DefaultKeywordEntry> = [
|
||||
{
|
||||
instructionId: 'builtin-translate',
|
||||
keywords: [
|
||||
{ keyword: '번역해줘', matchMode: 'prefix' },
|
||||
{ keyword: '번역', matchMode: 'prefix' },
|
||||
{ keyword: '영어로', matchMode: 'prefix' },
|
||||
{ keyword: 'translate', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-summarize',
|
||||
keywords: [
|
||||
{ keyword: '요약해줘', matchMode: 'prefix' },
|
||||
{ keyword: '요약', matchMode: 'prefix' },
|
||||
{ keyword: 'summarize', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-formal',
|
||||
keywords: [
|
||||
{ keyword: '다듬어줘', matchMode: 'prefix' },
|
||||
{ keyword: '다듬기', matchMode: 'prefix' },
|
||||
{ keyword: 'polish', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-explain-code',
|
||||
keywords: [
|
||||
{ keyword: '설명해줘', matchMode: 'prefix' },
|
||||
{ keyword: '설명', matchMode: 'prefix' },
|
||||
{ keyword: 'explain', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
// electron-store 키 (ConfigService와 별도 네임스페이스)
|
||||
// ============================================================
|
||||
|
||||
const STORE_KEY_RULES: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandRules'
|
||||
const STORE_KEY_ENABLED: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandsEnabled'
|
||||
|
||||
// ============================================================
|
||||
// 키워드 매칭 엔진
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다.
|
||||
* 키워드 앞뒤의 공백/구두점 경계를 존중한다.
|
||||
*/
|
||||
function matchKeyword(
|
||||
text: string,
|
||||
keyword: string,
|
||||
mode: KeywordMatchMode
|
||||
): { matched: boolean; cleanedText: string } {
|
||||
const trimmed = text.trim()
|
||||
const lowerText = trimmed.toLowerCase()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
|
||||
if (lowerKeyword.length === 0) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'prefix': {
|
||||
if (!lowerText.startsWith(lowerKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
// 키워드 뒤가 끝이거나 공백/구두점이어야 정확한 prefix 매칭
|
||||
const afterKeyword = trimmed.charAt(keyword.length)
|
||||
if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
const cleaned = trimmed.slice(keyword.length).trimStart()
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
|
||||
case 'suffix': {
|
||||
if (!lowerText.endsWith(lowerKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
// 키워드 앞이 시작이거나 공백/구두점이어야 정확한 suffix 매칭
|
||||
const beforeKeyword = trimmed.charAt(trimmed.length - keyword.length - 1)
|
||||
if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
const cleaned = trimmed.slice(0, trimmed.length - keyword.length).trimEnd()
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
|
||||
case 'contains': {
|
||||
const index = lowerText.indexOf(lowerKeyword)
|
||||
if (index === -1) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
// contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거
|
||||
const before = trimmed.slice(0, index)
|
||||
const after = trimmed.slice(index + keyword.length)
|
||||
const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim()
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWordBoundary(char: string): boolean {
|
||||
// 공백, 구두점, 한국어 조사/어미 앞의 경계
|
||||
return /[\s,.!?;:'"()[\]{}\-/]/.test(char)
|
||||
const configVoiceCommandStore: VoiceCommandStorePort = {
|
||||
loadRules: () => configGet('voiceCommandRules'),
|
||||
saveRules: (rules) => configSet('voiceCommandRules', rules),
|
||||
loadEnabled: () => configGet('voiceCommandsEnabled'),
|
||||
saveEnabled: (enabled) => configSet('voiceCommandsEnabled', enabled)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -146,10 +44,13 @@ class VoiceCommandService {
|
|||
private enabled = false
|
||||
private initialized = false
|
||||
|
||||
constructor(private readonly store: VoiceCommandStorePort = configVoiceCommandStore) {}
|
||||
|
||||
initialize(): void {
|
||||
if (this.initialized) return
|
||||
|
||||
this.loadRules()
|
||||
this.migrateLegacyDefaults()
|
||||
this.loadEnabled()
|
||||
this.initialized = true
|
||||
logger.info(
|
||||
|
|
@ -174,35 +75,21 @@ class VoiceCommandService {
|
|||
return noMatch
|
||||
}
|
||||
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) {
|
||||
const found = findRuleMatch(text, this.rules)
|
||||
if (!found) {
|
||||
return noMatch
|
||||
}
|
||||
|
||||
// priority 오름차순 정렬 (낮은 값 = 높은 우선순위)
|
||||
const sortedRules = [...this.rules]
|
||||
.filter((r) => r.enabled && r.keywords.length > 0)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
for (const kw of rule.keywords) {
|
||||
const result = matchKeyword(trimmed, kw.keyword, kw.matchMode)
|
||||
if (result.matched) {
|
||||
logger.info(
|
||||
`Voice command matched: rule="${rule.id}", keyword="${kw.keyword}", instruction="${rule.instructionId}"`
|
||||
)
|
||||
return {
|
||||
matched: true,
|
||||
ruleId: rule.id,
|
||||
instructionId: rule.instructionId,
|
||||
cleanedText: result.cleanedText,
|
||||
matchedKeyword: kw.keyword
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`Voice command matched: rule="${found.rule.id}", keyword="${found.keyword}", instruction="${found.rule.instructionId}"`
|
||||
)
|
||||
return {
|
||||
matched: true,
|
||||
ruleId: found.rule.id,
|
||||
instructionId: found.rule.instructionId,
|
||||
cleanedText: found.cleanedText,
|
||||
matchedKeyword: found.keyword
|
||||
}
|
||||
|
||||
return noMatch
|
||||
}
|
||||
|
||||
getAllRules(): VoiceCommandRule[] {
|
||||
|
|
@ -256,15 +143,7 @@ class VoiceCommandService {
|
|||
return
|
||||
}
|
||||
|
||||
for (const entry of DEFAULT_KEYWORDS) {
|
||||
this.rules.push({
|
||||
id: crypto.randomUUID(),
|
||||
instructionId: entry.instructionId,
|
||||
keywords: [...entry.keywords],
|
||||
enabled: true,
|
||||
priority: entry.priority
|
||||
})
|
||||
}
|
||||
this.rules = buildDefaultRules(() => crypto.randomUUID())
|
||||
|
||||
this.saveRules()
|
||||
logger.info(`Default voice command keywords initialized (${this.rules.length} rules)`)
|
||||
|
|
@ -276,9 +155,21 @@ class VoiceCommandService {
|
|||
|
||||
// ── Private ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 이전 버전이 저장한 맨 명사 기본 키워드('요약', '번역', 'explain' …)는 일반 받아쓰기
|
||||
* 첫 단어를 삼키므로, 사용자가 손대지 않은 기본값이면 명령형 기본값으로 교체한다.
|
||||
*/
|
||||
private migrateLegacyDefaults(): void {
|
||||
const { rules, migrated } = migrateLegacyDefaultRules(this.rules)
|
||||
if (migrated === 0) return
|
||||
this.rules = rules
|
||||
this.saveRules()
|
||||
logger.info(`Migrated ${migrated} legacy default voice command rule(s) to imperative keywords`)
|
||||
}
|
||||
|
||||
private loadRules(): void {
|
||||
try {
|
||||
const stored = configGet(STORE_KEY_RULES) as VoiceCommandRule[] | undefined
|
||||
const stored = this.store.loadRules()
|
||||
if (Array.isArray(stored) && stored.length > 0) {
|
||||
this.rules = stored
|
||||
return
|
||||
|
|
@ -291,7 +182,7 @@ class VoiceCommandService {
|
|||
|
||||
private saveRules(): void {
|
||||
try {
|
||||
configSet(STORE_KEY_RULES, this.rules)
|
||||
this.store.saveRules(this.rules)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to save voice command rules: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -301,7 +192,7 @@ class VoiceCommandService {
|
|||
|
||||
private loadEnabled(): void {
|
||||
try {
|
||||
const stored = configGet(STORE_KEY_ENABLED) as boolean | undefined
|
||||
const stored = this.store.loadEnabled()
|
||||
this.enabled = stored === true
|
||||
} catch {
|
||||
this.enabled = false
|
||||
|
|
@ -310,7 +201,7 @@ class VoiceCommandService {
|
|||
|
||||
private saveEnabled(): void {
|
||||
try {
|
||||
configSet(STORE_KEY_ENABLED, this.enabled)
|
||||
this.store.saveEnabled(this.enabled)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to save voice command enabled state: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -332,6 +223,11 @@ export function getVoiceCommandService(): VoiceCommandService {
|
|||
return instance
|
||||
}
|
||||
|
||||
/** 테스트/조립용: 저장소 포트를 주입한 새 인스턴스를 만든다 (싱글턴과 무관). */
|
||||
export function createVoiceCommandService(store?: VoiceCommandStorePort): VoiceCommandService {
|
||||
return new VoiceCommandService(store)
|
||||
}
|
||||
|
||||
export function resetVoiceCommandServiceForTests(): void {
|
||||
instance = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export class StreamingCaptionTrack {
|
|||
private _context = ''
|
||||
/** 버퍼를 자를 때마다 바뀐다 — 자르기 전에 시작한 인식 결과는 버린다 */
|
||||
private _epoch = 0
|
||||
/** 연속 인식 실패 수 — 성공하면 0. core 가 백오프·버퍼 상한을 정한다 */
|
||||
private _consecutiveFailures = 0
|
||||
private _lastFailureAt = 0
|
||||
private readonly _now: () => number
|
||||
|
||||
constructor(private readonly _deps: StreamingCaptionTrackDeps, initialContext = '') {
|
||||
|
|
@ -51,6 +54,11 @@ export class StreamingCaptionTrack {
|
|||
return this._buffer.length / BYTES_PER_MS
|
||||
}
|
||||
|
||||
/** 연속 인식 실패 수 (진단·테스트) */
|
||||
get consecutiveFailures(): number {
|
||||
return this._consecutiveFailures
|
||||
}
|
||||
|
||||
push(chunk: Buffer): void {
|
||||
this._buffer = this._buffer.length === 0 ? chunk : Buffer.concat([this._buffer, chunk])
|
||||
if (this._deps.isVoiced(chunk)) this._lastVoiceAt = this._now()
|
||||
|
|
@ -64,13 +72,21 @@ export class StreamingCaptionTrack {
|
|||
hasVoice: this._lastVoiceAt > 0,
|
||||
sinceVoiceMs: this._lastVoiceAt > 0 ? now - this._lastVoiceAt : Number.MAX_SAFE_INTEGER,
|
||||
sincePartialMs: now - this._lastPartialAt,
|
||||
busy: this._busy
|
||||
busy: this._busy,
|
||||
consecutiveFailures: this._consecutiveFailures,
|
||||
sinceFailureMs: this._consecutiveFailures > 0 ? now - this._lastFailureAt : Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
|
||||
if (action === 'trim-idle') {
|
||||
this._keepTail(CAPTION_STREAMING_DEFAULTS.idleKeepMs)
|
||||
return
|
||||
}
|
||||
if (action === 'drop-overflow') {
|
||||
// 인식이 계속 실패하는 동안 쌓인 앞부분은 버린다 — 그 구간은 자막으로 남지 않는다.
|
||||
this._keepTail(CAPTION_STREAMING_DEFAULTS.failureRetainMs)
|
||||
this._epoch += 1
|
||||
return
|
||||
}
|
||||
if (action === 'partial') return this._run(() => this._partial())
|
||||
if (action === 'finalize') return this._run(() => this._finalize())
|
||||
if (action === 'force-commit') {
|
||||
|
|
@ -92,7 +108,10 @@ export class StreamingCaptionTrack {
|
|||
this._busy = true
|
||||
try {
|
||||
await job()
|
||||
this._consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
this._consecutiveFailures += 1
|
||||
this._lastFailureAt = this._now()
|
||||
// 보고 경로가 던져도 트랙(과 이를 기다리는 flush/stop)은 멈추지 않는다
|
||||
try {
|
||||
this._deps.onError(error)
|
||||
|
|
|
|||
62
apps/desktop/src/main/services/cloud/cloud-credentials.ts
Normal file
62
apps/desktop/src/main/services/cloud/cloud-credentials.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// src/main/services/cloud/cloud-credentials.ts
|
||||
// 계정 자격 증명 포트 — Premium LLM·Cloud STT·음성 대화가 CloudSyncService 전체가 아니라
|
||||
// "지금 로그인한 계정의 자격 증명"만 보게 한다(ISP/DIP).
|
||||
//
|
||||
// 정본은 CloudSyncService 의 세션 상태다. 로그아웃(서버 응답과 무관)하면 이 포트의 모든 접근자가
|
||||
// null / 'no session' 을 돌려준다 — Supabase 클라이언트에 남은 세션으로 옛 계정의 쿼터를 쓰지 않게.
|
||||
|
||||
import { getCloudSyncService } from '../CloudSyncService'
|
||||
|
||||
export interface CloudFunctionResult {
|
||||
data: unknown
|
||||
error: { message: string } | null
|
||||
}
|
||||
|
||||
export interface CloudFunctionStreamResult {
|
||||
stream: ReadableStream<Uint8Array> | null
|
||||
error: { message: string } | null
|
||||
}
|
||||
|
||||
export interface CloudInvokeOptions {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface CloudCredentials {
|
||||
/** 클라우드가 설정됐고 로그인한 계정이 있으면 true */
|
||||
hasCredentials(): boolean
|
||||
/** 현재 계정의 access token(필요하면 갱신). 로그아웃 상태면 null */
|
||||
accessToken(): Promise<string | null>
|
||||
/** Edge Function 호출. 로그아웃 상태면 error 를 돌려준다 */
|
||||
invoke(name: string, body: Record<string, unknown> | FormData, options?: CloudInvokeOptions): Promise<CloudFunctionResult>
|
||||
/** Edge Function SSE 호출. 로그아웃 상태면 error 를 돌려준다 */
|
||||
invokeStream(name: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<CloudFunctionStreamResult>
|
||||
}
|
||||
|
||||
/** Cloud STT 드라이버가 쓰는 자격 증명 + 엔드포인트 설정 */
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
|
||||
/** CloudSyncService 싱글톤을 자격 증명 포트로 감싼 기본 어댑터(호출 시점에 해석 — 테스트 모킹과 순환 import 안전). */
|
||||
export const cloudSyncCredentials: CloudCredentials = {
|
||||
hasCredentials: () => {
|
||||
const cloud = getCloudSyncService()
|
||||
return cloud.isEnabled() && cloud.isAuthenticated()
|
||||
},
|
||||
accessToken: () => getCloudSyncService().getAccessToken(),
|
||||
invoke: (name, body, options) =>
|
||||
options ? getCloudSyncService().invokeFunction(name, body, options) : getCloudSyncService().invokeFunction(name, body),
|
||||
invokeStream: (name, body, signal) => getCloudSyncService().invokeFunctionStream(name, body, signal),
|
||||
}
|
||||
|
||||
/** 기본 Cloud STT 게이트웨이 — 토큰은 자격 증명 포트에서, 엔드포인트는 설정에서. */
|
||||
export function createCloudSttGateway(credentials: CloudCredentials = cloudSyncCredentials): CloudSttGateway {
|
||||
return {
|
||||
getAccessToken: () => credentials.accessToken(),
|
||||
getSupabaseUrl: () => getCloudSyncService().getSupabaseUrl(),
|
||||
getAnonKey: () => getCloudSyncService().getAnonKey(),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
// src/main/services/dictionary/dictionary-file-codec.ts
|
||||
// 사전 내보내기/가져오기 파일 형식(CSV·JSON) — 순수 함수만 둔다 (fs/dialog/DB/동기화 의존 없음).
|
||||
// DictionaryService 는 파일 IO·DB 쓰기·동기화 push 만 맡고, 형식 변환과 행 검증은 여기서 한다.
|
||||
|
||||
import { validateDictionaryDraft, type DictionaryWriteProblem } from '@d3ro/core/dictionary-policy'
|
||||
import type { DictionaryEntry } from '@d3ro/core/types'
|
||||
|
||||
export type DictionaryFileFormat = 'csv' | 'json'
|
||||
|
||||
export const DICTIONARY_CSV_HEADER = [
|
||||
'word',
|
||||
'pronunciation',
|
||||
'category',
|
||||
'usageCount',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
] as const
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
|
||||
return needsQuotes ? `"${escaped}"` : escaped
|
||||
}
|
||||
|
||||
function parseCsvRows(input: string): string[][] {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (input[i + 1] === '"') {
|
||||
field += '"'
|
||||
i += 1
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = true
|
||||
} else if (char === ',') {
|
||||
row.push(field)
|
||||
field = ''
|
||||
} else if (char === '\n') {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
row = []
|
||||
field = ''
|
||||
} else if (char === '\r') {
|
||||
// skip CR; LF terminates the row
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
}
|
||||
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
|
||||
}
|
||||
|
||||
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickNumber(source: Record<string, unknown>, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string | null): DictionaryEntry['category'] {
|
||||
if (value === 'auto' || value === 'technical' || value === 'user') return value
|
||||
return 'user'
|
||||
}
|
||||
|
||||
function serializeJson(entries: readonly DictionaryEntry[]): string {
|
||||
return JSON.stringify({ entries }, null, 2)
|
||||
}
|
||||
|
||||
function serializeCsv(entries: readonly DictionaryEntry[]): string {
|
||||
const lines = [DICTIONARY_CSV_HEADER.join(',')]
|
||||
for (const entry of entries) {
|
||||
lines.push(
|
||||
[
|
||||
csvCell(entry.word),
|
||||
csvCell(entry.pronunciation ?? ''),
|
||||
csvCell(entry.category),
|
||||
csvCell(entry.usageCount),
|
||||
csvCell(entry.createdAt),
|
||||
csvCell(entry.updatedAt)
|
||||
].join(',')
|
||||
)
|
||||
}
|
||||
return `\uFEFF${lines.join('\r\n')}\r\n`
|
||||
}
|
||||
|
||||
/** 사전 항목을 파일 내용으로 직렬화한다. */
|
||||
export function serializeDictionary(
|
||||
entries: readonly DictionaryEntry[],
|
||||
format: DictionaryFileFormat
|
||||
): string {
|
||||
return format === 'csv' ? serializeCsv(entries) : serializeJson(entries)
|
||||
}
|
||||
|
||||
function parseJson(raw: string): Array<Record<string, unknown>> {
|
||||
const data: unknown = JSON.parse(raw)
|
||||
let list: unknown
|
||||
if (Array.isArray(data)) {
|
||||
list = data
|
||||
} else if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
Array.isArray((data as { entries?: unknown }).entries)
|
||||
) {
|
||||
list = (data as { entries: unknown[] }).entries
|
||||
} else {
|
||||
throw new Error('expected an array or an object with an "entries" array')
|
||||
}
|
||||
return (list as unknown[]).filter(
|
||||
(item): item is Record<string, unknown> => !!item && typeof item === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
function parseCsv(raw: string): Array<Record<string, unknown>> {
|
||||
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
|
||||
if (rows.length === 0) {
|
||||
throw new Error('empty CSV')
|
||||
}
|
||||
const header = rows[0].map((cell) => cell.trim())
|
||||
if (!header.includes('word')) {
|
||||
throw new Error('missing "word" column')
|
||||
}
|
||||
return rows.slice(1).map((cells) => {
|
||||
const record: Record<string, unknown> = {}
|
||||
header.forEach((key, index) => {
|
||||
record[key] = cells[index] ?? ''
|
||||
})
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
/** 파일 내용을 레코드 목록으로 파싱한다. 형식이 맞지 않으면 Error 를 던진다. */
|
||||
export function parseDictionaryFile(
|
||||
raw: string,
|
||||
format: DictionaryFileFormat
|
||||
): Array<Record<string, unknown>> {
|
||||
return format === 'json' ? parseJson(raw) : parseCsv(raw)
|
||||
}
|
||||
|
||||
/** 가져올 한 행의 정규화된 쓰기 값 (id 는 서비스가 붙인다) */
|
||||
export interface DictionaryImportRow {
|
||||
word: string
|
||||
pronunciation: string | null
|
||||
category: DictionaryEntry['category']
|
||||
usageCount: number
|
||||
lastUsedAt: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type DictionaryImportRowResult =
|
||||
| { ok: true; row: DictionaryImportRow }
|
||||
| { ok: false; problem: DictionaryWriteProblem }
|
||||
|
||||
/**
|
||||
* 파싱한 레코드 하나를 저장할 행으로 바꾼다. 빈 단어나 서버 길이 제한을 넘는 단어·발음은 거부한다
|
||||
* (받아들이면 로컬에만 남고 서버가 22023 으로 거부해 다른 기기로 동기화되지 않는다).
|
||||
*/
|
||||
export function toDictionaryImportRow(
|
||||
record: Record<string, unknown>,
|
||||
now: number
|
||||
): DictionaryImportRowResult {
|
||||
const draft = validateDictionaryDraft({
|
||||
word: pickString(record, ['word']) ?? '',
|
||||
pronunciation: pickString(record, ['pronunciation'])
|
||||
})
|
||||
if (!draft.ok) return { ok: false, problem: draft }
|
||||
const createdAt = pickNumber(record, ['createdAt', 'created_at'])
|
||||
const updatedAt = pickNumber(record, ['updatedAt', 'updated_at'])
|
||||
const usageCount = pickNumber(record, ['usageCount', 'usage_count'])
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
word: draft.draft.word,
|
||||
pronunciation: draft.draft.pronunciation,
|
||||
category: normalizeCategory(pickString(record, ['category'])),
|
||||
usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0,
|
||||
lastUsedAt: pickNumber(record, ['lastUsedAt', 'last_used_at']),
|
||||
createdAt: createdAt ?? now,
|
||||
updatedAt: updatedAt ?? createdAt ?? now
|
||||
}
|
||||
}
|
||||
}
|
||||
56
apps/desktop/src/main/services/llm/model-residency.ts
Normal file
56
apps/desktop/src/main/services/llm/model-residency.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// src/main/services/llm/model-residency.ts
|
||||
//
|
||||
// Ollama 모델 상주(keep_alive) 추정 — 로컬 LLM 의 "모든" 요청을 기록한다.
|
||||
//
|
||||
// Ollama 는 모델의 만료 시각을 그 모델에 온 가장 최근 요청의 keep_alive 로 다시 정한다.
|
||||
// 제안 서비스가 자기 요청(10m)만 보고 warm 을 판정하면, 받아쓰기(기본 5m)·음성 대화(2m)가
|
||||
// 같은 모델을 더 짧게 덮어쓴 뒤에도 warm 으로 믿어 콜드 모델로 요청해 타임아웃됐다.
|
||||
|
||||
/** keep_alive 를 생략하면 Ollama 서버 기본값(5분)이 적용된다. */
|
||||
export const OLLAMA_DEFAULT_KEEP_ALIVE_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* Ollama keep_alive 값을 ms 로 푼다. 음수는 "무기한"(Infinity), 0 은 즉시 내림, 해석할 수 없으면 기본값.
|
||||
* 문자열은 Go duration 형식('10m', '30s', '1h30m')과 초 단위 숫자 문자열을 받는다.
|
||||
*/
|
||||
export function parseKeepAliveMs(value: string | number | undefined | null): number {
|
||||
if (value === undefined || value === null || value === '') return OLLAMA_DEFAULT_KEEP_ALIVE_MS
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return OLLAMA_DEFAULT_KEEP_ALIVE_MS
|
||||
return value < 0 ? Number.POSITIVE_INFINITY : value * 1000
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (/^-?\d+(?:\.\d+)?$/u.test(trimmed)) return parseKeepAliveMs(Number(trimmed))
|
||||
if (trimmed.startsWith('-')) return Number.POSITIVE_INFINITY
|
||||
const unitMs: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }
|
||||
let total = 0
|
||||
let matched = ''
|
||||
for (const part of trimmed.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/gu)) {
|
||||
total += Number(part[1]) * unitMs[part[2]]
|
||||
matched += part[0]
|
||||
}
|
||||
return matched.length > 0 && matched === trimmed ? total : OLLAMA_DEFAULT_KEEP_ALIVE_MS
|
||||
}
|
||||
|
||||
/** 모델이 언제까지 메모리에 남아 있을지 알려 주는 포트 (제안 온기 판정이 읽는다). */
|
||||
export interface ModelResidencyPort {
|
||||
/** 알려진 만료 시각(ms). 이 앱에서 그 모델로 요청한 적이 없으면 null */
|
||||
residentUntil(model: string): number | null
|
||||
}
|
||||
|
||||
/** 요청 완료마다 (모델, keep_alive, 완료 시각)을 기록한다. 가장 최근 요청이 만료 시각을 정한다. */
|
||||
export class ModelResidencyLedger implements ModelResidencyPort {
|
||||
private readonly _expiresAt = new Map<string, number>()
|
||||
|
||||
noteRequest(model: string, keepAlive: string | number | undefined, completedAt: number): void {
|
||||
this._expiresAt.set(model, completedAt + parseKeepAliveMs(keepAlive))
|
||||
}
|
||||
|
||||
residentUntil(model: string): number | null {
|
||||
return this._expiresAt.get(model) ?? null
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._expiresAt.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// src/main/services/meeting/diarization-context.ts
|
||||
//
|
||||
// LLM 화자 추정을 조각(1500자)마다 나눠 돌릴 때 조각 사이에 화자 번호를 이어 주는 순수 규칙.
|
||||
//
|
||||
// 조각마다 독립적으로 물으면 모델이 조각마다 처음 말한 사람을 '화자 1' 로 다시 매겨, 긴 회의에서
|
||||
// '화자 1' 이 3~5분마다 다른 사람을 가리켰다. 앞 조각의 라벨된 마지막 몇 줄과 지금까지의 화자 목록을
|
||||
// 다음 조각에 참고로 넘긴다. 참고 블록은 사용자 메시지 안에 경계를 두어 넣고(전사 원문을 시스템 권한으로
|
||||
// 올리지 않는다), 모델이 그 줄을 다시 출력하면 걷어낸다.
|
||||
|
||||
const SPEAKER_LABEL = /화자\s*(\d+)/gu
|
||||
|
||||
/** 텍스트에 나온 화자 라벨 ('화자 1' …) — 번호 순, 중복 없음 */
|
||||
export function extractSpeakerLabels(text: string): string[] {
|
||||
const numbers = new Set<number>()
|
||||
for (const match of text.matchAll(SPEAKER_LABEL)) numbers.add(Number(match[1]))
|
||||
return [...numbers].sort((a, b) => a - b).map((n) => `화자 ${n}`)
|
||||
}
|
||||
|
||||
/** 마지막 n 줄 (빈 줄 제외) */
|
||||
export function lastLines(text: string, count: number): string[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-count)
|
||||
}
|
||||
|
||||
export const DIARIZATION_CONTEXT_OPEN = '[이전 조각 끝부분 — 화자 번호를 이어 쓰기 위한 참고입니다. 다시 출력하지 마세요]'
|
||||
export const DIARIZATION_CONTEXT_CLOSE = '[/이전 조각 끝부분]'
|
||||
|
||||
/** 다음 조각의 사용자 메시지 — 앞 조각의 라벨된 꼬리를 참고 블록으로 앞에 붙인다. 첫 조각은 그대로. */
|
||||
export function buildDiarizationChunkInput(chunk: string, previousTail: readonly string[]): string {
|
||||
if (previousTail.length === 0) return chunk
|
||||
return `${DIARIZATION_CONTEXT_OPEN}\n${previousTail.join('\n')}\n${DIARIZATION_CONTEXT_CLOSE}\n\n${chunk}`
|
||||
}
|
||||
|
||||
/** 시스템 프롬프트에 덧붙일 화자 연속성 지시 — 화자 목록만 담는다(전사 원문은 넣지 않는다). */
|
||||
export function diarizationContinuityHint(knownSpeakers: readonly string[]): string {
|
||||
if (knownSpeakers.length === 0) return ''
|
||||
return `\n앞부분에서 이미 ${knownSpeakers.join(', ')} 로 표시했습니다. 같은 사람에게는 같은 번호를 이어 쓰고, 새 사람에게만 다음 번호를 주세요.`
|
||||
}
|
||||
|
||||
/** 모델이 참고 블록(경계 표시·앞 조각 줄)을 다시 출력했으면 걷어낸다. */
|
||||
export function stripEchoedContext(output: string, previousTail: readonly string[]): string {
|
||||
if (previousTail.length === 0) return output.trim()
|
||||
const echoed = new Set(previousTail.map((line) => line.trim()))
|
||||
return output
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed === DIARIZATION_CONTEXT_OPEN || trimmed === DIARIZATION_CONTEXT_CLOSE) return false
|
||||
return !echoed.has(trimmed)
|
||||
})
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// src/main/services/meeting/local-recording-ledger.ts
|
||||
//
|
||||
// "이 기기에서 시작한 회의 녹음" 기록 (로컬 전용 sync_state, 서버로 가지 않는다).
|
||||
//
|
||||
// 중단 복구(recoverInterruptedSessions)는 이 기기가 녹음하다 끊긴 회의만 닫아야 한다. 동기화로 들어온
|
||||
// 폰의 녹음 중·처리 중 회의까지 '중단됨' 오류로 닫고 push 하면, 폰의 녹음 처리가 깨지고 서버의 완료된
|
||||
// 전사가 오래된 로컬 사본(NULL)으로 덮였다.
|
||||
|
||||
import { deleteSyncState, getSyncState, setSyncState } from '../sync/sync-outbox'
|
||||
|
||||
const KEY_PREFIX = 'meeting:local-recording:'
|
||||
|
||||
export interface LocalRecordingLedger {
|
||||
/** 이 기기에서 녹음을 시작했다 */
|
||||
mark(meetingId: string): void
|
||||
/** 녹음이 저장·종료됐다 (더 복구할 것이 없다) */
|
||||
clear(meetingId: string): void
|
||||
/** 이 기기에서 시작해 아직 끝나지 않은 녹음인가 */
|
||||
isLocal(meetingId: string): boolean
|
||||
}
|
||||
|
||||
export const syncStateRecordingLedger: LocalRecordingLedger = {
|
||||
mark: (meetingId) => setSyncState(`${KEY_PREFIX}${meetingId}`, '1'),
|
||||
clear: (meetingId) => deleteSyncState(`${KEY_PREFIX}${meetingId}`),
|
||||
isLocal: (meetingId) => getSyncState(`${KEY_PREFIX}${meetingId}`) === '1',
|
||||
}
|
||||
|
||||
/**
|
||||
* 중단 복구 대상인가 — 이 기기에서 시작한 녹음이거나, 기록이 없던 구버전 녹음 중 이 기기에 전사
|
||||
* 체크포인트가 남은 것. 전사도 표식도 없는 행은 다른 기기(폰)의 진행 중인 회의일 수 있어 건드리지 않는다.
|
||||
*/
|
||||
export function isRecoverableMeeting(
|
||||
row: { id: string; rawTranscript: string | null },
|
||||
ledger: LocalRecordingLedger
|
||||
): boolean {
|
||||
if (ledger.isLocal(row.id)) return true
|
||||
return !!row.rawTranscript && row.rawTranscript.trim().length > 0
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
// 긴 회의 전사를 LLM 한 번의 입력 한도 안으로 줄이는 map-reduce 단계.
|
||||
//
|
||||
// 문서 생성·요약은 전사 전체를 메시지 하나로 보냈고, 8,000자를 넘는 회의(대략 25분 이상)는 llm-proxy 가
|
||||
// 400 으로 거부했다. 한도를 넘으면 전사를 줄 경계 조각으로 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을
|
||||
// 400 으로 거부했다. 한도를 넘으면 전사를 한도 이하 조각(줄 경계, 줄바꿈 없는 긴 줄은 문장·공백·글자 수 경계)으로
|
||||
// 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을
|
||||
// 이어 붙여(reduce 입력) 호출자가 한 번에 보낸다. 몇 번 줄여도 넘으면 조용히 자르지 않고 실패시킨다.
|
||||
//
|
||||
// LLM 은 포트(generate)로만 받는다 — 게이트웨이든 가짜든 된다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
// src/main/services/meeting/transcript-revision-store.ts
|
||||
//
|
||||
// 회의 전사 수정본(editedTranscript) 쓰기의 단일 관문 (SRP · DIP).
|
||||
//
|
||||
// 수동 편집·실시간 세그먼트 수정·AI 다듬기·화자 구분이 각자 editedTranscript 를 덮어쓰면서 기준본 규칙이
|
||||
// 제각각이었다(다듬기는 원문, 화자 구분은 수정본). 그래서 다듬기가 화자 라벨과 직접 수정을 지웠고,
|
||||
// LLM 이 도는 동안 한 수동 편집이 마지막 쓰기에 덮였다. 이 저장소가 "어떤 기준본 위의 수정인가" 를
|
||||
// revision(원문·수정본 내용 해시)으로 확인한다(compare-and-set).
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface TranscriptRevision {
|
||||
raw: string | null
|
||||
edited: string | null
|
||||
/** raw·edited 내용 해시 — 제목 변경·상태 전이·동기화처럼 전사와 무관한 updatedAt 변화에는 흔들리지 않는다 */
|
||||
revision: string
|
||||
}
|
||||
|
||||
export type TranscriptWriteResult = 'ok' | 'conflict' | 'not-found'
|
||||
|
||||
export interface MeetingTranscriptStore {
|
||||
read(sessionId: string): TranscriptRevision | null
|
||||
/**
|
||||
* 수정본을 쓴다. baseRevision 을 주면 그 사이 전사가 바뀌었을 때 쓰지 않고 'conflict' 를 돌려준다
|
||||
* (LLM 작업 경로). 생략하면 무조건 쓴다(사용자의 수동 저장).
|
||||
*/
|
||||
writeEdited(sessionId: string, editedTranscript: string, baseRevision?: string): TranscriptWriteResult
|
||||
}
|
||||
|
||||
export function transcriptRevision(raw: string | null, edited: string | null): string {
|
||||
return createHash('sha256').update(JSON.stringify([raw, edited])).digest('hex')
|
||||
}
|
||||
|
||||
/** 다시 쓰기(다듬기·화자 구분)의 기준본 — 사용자의 수정·화자 라벨이 담긴 수정본이 우선이다. */
|
||||
export function transcriptBase(revision: Pick<TranscriptRevision, 'raw' | 'edited'>): string | null {
|
||||
return revision.edited ?? revision.raw
|
||||
}
|
||||
|
||||
/** SQLite 어댑터가 쓰는 최소 행 접근 — better-sqlite3 는 동기라 read→compare→write 가 원자적이다. */
|
||||
export interface TranscriptRowAccess {
|
||||
readRow(sessionId: string): { rawTranscript: string | null; editedTranscript: string | null } | undefined
|
||||
writeEdited(sessionId: string, editedTranscript: string, now: number): void
|
||||
/** 쓰기 뒤 동기화 예약 */
|
||||
onWritten(sessionId: string): void
|
||||
}
|
||||
|
||||
export function createTranscriptStore(access: TranscriptRowAccess): MeetingTranscriptStore {
|
||||
return {
|
||||
read(sessionId) {
|
||||
const row = access.readRow(sessionId)
|
||||
if (!row) return null
|
||||
return {
|
||||
raw: row.rawTranscript,
|
||||
edited: row.editedTranscript,
|
||||
revision: transcriptRevision(row.rawTranscript, row.editedTranscript),
|
||||
}
|
||||
},
|
||||
writeEdited(sessionId, editedTranscript, baseRevision) {
|
||||
const row = access.readRow(sessionId)
|
||||
if (!row) return 'not-found'
|
||||
if (baseRevision !== undefined && transcriptRevision(row.rawTranscript, row.editedTranscript) !== baseRevision) {
|
||||
return 'conflict'
|
||||
}
|
||||
access.writeEdited(sessionId, editedTranscript, Date.now())
|
||||
access.onWritten(sessionId)
|
||||
return 'ok'
|
||||
},
|
||||
}
|
||||
}
|
||||
44
apps/desktop/src/main/services/rag/remote-document.ts
Normal file
44
apps/desktop/src/main/services/rag/remote-document.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// src/main/services/rag/remote-document.ts
|
||||
// 다른 기기에서 받은 지식 문서를 로컬에 어떻게 반영할지 정하는 순수 정책.
|
||||
// 원문 청크 자체를 버전으로 쓴다 — 서버에 별도 리비전 컬럼이 없어도 재색인(원문 변경)을 알아챈다.
|
||||
|
||||
import type { RAGDocument } from '@d3ro/core/types'
|
||||
|
||||
/** 동기화로 받은 지식 문서(원문 청크만, 임베딩 없음) */
|
||||
export interface RemoteKnowledgeDocument {
|
||||
id: string
|
||||
fileName: string
|
||||
fileType: RAGDocument['fileType']
|
||||
chunks: readonly string[]
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
export type RemoteDocumentPlan =
|
||||
| { kind: 'skip' }
|
||||
| { kind: 'insert'; chunks: string[] }
|
||||
| { kind: 'replace'; chunks: string[] }
|
||||
|
||||
/** 빈 청크는 저장·임베딩 대상이 아니다 */
|
||||
export function usableChunks(chunks: readonly string[]): string[] {
|
||||
return chunks.filter((c) => c.trim().length > 0)
|
||||
}
|
||||
|
||||
export function sameChunks(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((content, i) => content === b[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param localChunks 로컬에 저장된 원문 청크(chunkIndex 순). 문서가 로컬에 없으면 null.
|
||||
* @param remoteChunks 서버에서 받은 완전한 원문 청크.
|
||||
* - 로컬에 없으면 insert
|
||||
* - 로컬에 있고 원문이 같으면 skip(자기 push의 에코, 제목만 바뀐 행 등)
|
||||
* - 로컬에 있고 원문이 다르면 replace(다른 기기에서 재색인됨)
|
||||
* 받은 청크가 전부 비어 있으면 로컬을 비우지 않고 skip한다.
|
||||
*/
|
||||
export function planRemoteDocument(localChunks: readonly string[] | null, remoteChunks: readonly string[]): RemoteDocumentPlan {
|
||||
const chunks = usableChunks(remoteChunks)
|
||||
if (chunks.length === 0) return { kind: 'skip' }
|
||||
if (localChunks === null) return { kind: 'insert', chunks }
|
||||
if (sameChunks(usableChunks(localChunks), chunks)) return { kind: 'skip' }
|
||||
return { kind: 'replace', chunks }
|
||||
}
|
||||
55
apps/desktop/src/main/services/recording/recording-store.ts
Normal file
55
apps/desktop/src/main/services/recording/recording-store.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// src/main/services/recording/recording-store.ts
|
||||
//
|
||||
// 받아쓰기 녹음 저장 포트 (DIP) — 오케스트레이터(VoiceModeService)가 electron 경로·fs·WAV 헤더를
|
||||
// 직접 들지 않게 한다. 경로 규칙의 정본은 history-deletion.recordingsDir(), WAV 인코딩의 정본은
|
||||
// stt/audio-utils.pcmToWav 다.
|
||||
//
|
||||
// 저장은 완료 이벤트보다 먼저 끝나야 한다: 히스토리 행(audioLocalPath)과 history_audio 동기화가
|
||||
// 파일이 생기기 전에 돌면, flush 가 "파일 없음"을 성공으로 처리해 녹음이 영영 올라가지 않았다.
|
||||
|
||||
import path from 'path'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { getLogger } from '../LoggerService'
|
||||
import { pcmToWav } from '../stt/audio-utils'
|
||||
|
||||
const logger = getLogger('RecordingStore')
|
||||
|
||||
export interface RecordingStore {
|
||||
/**
|
||||
* 16kHz 16-bit mono PCM 을 WAV 로 저장하고 실제 경로를 돌려준다.
|
||||
* 오디오가 없거나 저장에 실패하면 null — 호출자는 이 경로만 히스토리에 기록한다. 예외를 던지지 않는다.
|
||||
*/
|
||||
save(sessionId: string, pcm: Buffer | null): Promise<string | null>
|
||||
}
|
||||
|
||||
export interface FsRecordingStoreDeps {
|
||||
/** 녹음 디렉터리 (기본: history-deletion.recordingsDir — 삭제 경로와 같은 정본) */
|
||||
resolveDir(): Promise<string>
|
||||
mkdir(dir: string): Promise<unknown>
|
||||
writeFile(filePath: string, data: Buffer): Promise<void>
|
||||
}
|
||||
|
||||
const defaultDeps: FsRecordingStoreDeps = {
|
||||
resolveDir: async () => (await import('../history-deletion')).recordingsDir(),
|
||||
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
||||
writeFile: (filePath, data) => writeFile(filePath, data),
|
||||
}
|
||||
|
||||
export function createFsRecordingStore(deps: FsRecordingStoreDeps = defaultDeps): RecordingStore {
|
||||
return {
|
||||
async save(sessionId: string, pcm: Buffer | null): Promise<string | null> {
|
||||
if (!pcm || pcm.length === 0) return null
|
||||
try {
|
||||
const dir = await deps.resolveDir()
|
||||
await deps.mkdir(dir)
|
||||
const wavPath = path.join(dir, `${sessionId}.wav`)
|
||||
await deps.writeFile(wavPath, pcmToWav(pcm, 16000, 1, 16))
|
||||
logger.info(`Audio saved: ${wavPath} (${Math.round(pcm.length / 1024)}KB)`)
|
||||
return wavPath
|
||||
} catch (error) {
|
||||
logger.warn(`Audio save failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,53 @@ export interface RuntimeComponentIndex {
|
|||
parts: RuntimePart[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임 번들이 만들어진 플랫폼·아키텍처. 사이드카(PyInstaller)와 ffmpeg 는 네이티브
|
||||
* 실행 파일이라 빌드한 호스트에서만 돈다. 인덱스가 이 값을 밝히면 다른 플랫폼 앱은
|
||||
* 부품(~160MB)을 받기 전에 거부한다.
|
||||
*/
|
||||
export interface RuntimeTarget {
|
||||
platform: string
|
||||
arch: string
|
||||
}
|
||||
|
||||
/** 인덱스를 거부한 이유 — 호출 측이 "기존 엔진으로 버틸지" 판단할 때 쓴다 */
|
||||
export type RuntimeIndexRejection = 'feed-outdated' | 'platform-mismatch'
|
||||
|
||||
/**
|
||||
* 형식은 맞지만 이 앱이 쓸 수 없는 인덱스. 형식 오류(invalid)와 달리 이유를 싣는다.
|
||||
* - feed-outdated : feed 버전이 이 앱의 최소 요구 버전보다 낮다 (런타임 게시가 앱보다 늦음)
|
||||
* - platform-mismatch : 다른 플랫폼·아키텍처용 번들이다 (예: macOS 앱이 Windows 엔진을 받음)
|
||||
*/
|
||||
export class RuntimeIndexRejectedError extends D3ROError {
|
||||
readonly reason: RuntimeIndexRejection
|
||||
|
||||
constructor(reason: RuntimeIndexRejection, message: string) {
|
||||
super(ErrorCode.ConfigReadFailed, message, { reason })
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
export function isRuntimeIndexRejected(
|
||||
err: unknown,
|
||||
reason?: RuntimeIndexRejection,
|
||||
): err is RuntimeIndexRejectedError {
|
||||
return err instanceof RuntimeIndexRejectedError && (reason === undefined || err.reason === reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스가 밝힌 대상 플랫폼. platform/arch 가 없는 예전 인덱스는 null(미지정)로 본다 —
|
||||
* 그런 인덱스는 게시 게이트(scripts/ci/lib/runtime-feed-gate.mjs)가 더는 통과시키지 않는다.
|
||||
*/
|
||||
function parseIndexTarget(raw: Record<string, unknown>): RuntimeTarget | null {
|
||||
const { platform, arch } = raw
|
||||
if (platform === undefined && arch === undefined) return null
|
||||
if (typeof platform !== 'string' || platform.length === 0 || typeof arch !== 'string' || arch.length === 0) {
|
||||
throw invalid('platform/arch 가 비어 있거나 문자열이 아닙니다')
|
||||
}
|
||||
return { platform, arch }
|
||||
}
|
||||
|
||||
/** parseRuntimeIndex 의 결과 — 검증된 구성 요소 항목과 인덱스 버전 */
|
||||
export interface ResolvedRuntimeEntry {
|
||||
version: string
|
||||
|
|
@ -131,11 +178,15 @@ function parsePart(raw: unknown, index: number): RuntimePart {
|
|||
* - 부품·아카이브 이름은 경로 구분자 없는 단순 파일 이름이어야 한다.
|
||||
* - 인덱스 version 이 이 앱의 최소 버전보다 낮으면 거부한다 — 설치해 봐야 곧바로
|
||||
* "낡았다" 고 판단돼 매번 다시 받는 루프가 되기 때문이다.
|
||||
* - `target` 을 주면, 인덱스가 밝힌 platform/arch 가 다를 때 거부한다 — 다른 플랫폼
|
||||
* 엔진은 받아 풀어도 검증에서 떨어져 매번 ~160MB 를 다시 받는 루프가 되기 때문이다.
|
||||
* 두 거부는 RuntimeIndexRejectedError 로 던져 형식 오류와 구분한다.
|
||||
*/
|
||||
export function parseRuntimeIndex(
|
||||
raw: unknown,
|
||||
component: RuntimeComponent,
|
||||
minVersion: string | null = RUNTIME_MIN_VERSION[component],
|
||||
target?: RuntimeTarget,
|
||||
): ResolvedRuntimeEntry {
|
||||
if (!isRecord(raw)) throw invalid('최상위 값이 객체가 아닙니다')
|
||||
|
||||
|
|
@ -144,6 +195,19 @@ export function parseRuntimeIndex(
|
|||
throw invalid('version 이 semver 문자열이 아닙니다')
|
||||
}
|
||||
|
||||
const indexTarget = parseIndexTarget(raw)
|
||||
if (
|
||||
target &&
|
||||
indexTarget &&
|
||||
(indexTarget.platform !== target.platform || indexTarget.arch !== target.arch)
|
||||
) {
|
||||
throw new RuntimeIndexRejectedError(
|
||||
'platform-mismatch',
|
||||
`이 플랫폼(${target.platform}-${target.arch})용 런타임이 feed에 없습니다 ` +
|
||||
`(feed=${indexTarget.platform}-${indexTarget.arch}, version=${version})`,
|
||||
)
|
||||
}
|
||||
|
||||
const components = raw.components
|
||||
if (!isRecord(components)) throw invalid('components 가 없습니다')
|
||||
|
||||
|
|
@ -169,8 +233,8 @@ export function parseRuntimeIndex(
|
|||
}
|
||||
|
||||
if (!isRuntimeVersionSatisfied(version, minVersion)) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigReadFailed,
|
||||
throw new RuntimeIndexRejectedError(
|
||||
'feed-outdated',
|
||||
`런타임 feed의 ${component} 버전(${version})이 이 앱의 최소 요구 버전(${String(minVersion)})보다 낮습니다`,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,3 +86,20 @@ export function createProbeWav(durationMs: number = 500, sampleRate: number = 16
|
|||
export function bufferToBody(data: Buffer): BodyInit {
|
||||
return data as unknown as BodyInit
|
||||
}
|
||||
|
||||
/** 16kHz 16-bit mono PCM 의 초당 바이트 수 */
|
||||
export const PCM16_MONO_16K_BYTES_PER_SECOND = 16000 * 2
|
||||
|
||||
/**
|
||||
* 최종 전사 요청의 응답 기한 — 오디오 길이에 비례한다.
|
||||
*
|
||||
* 고정 120초는 CUDA 없는 PC(CPU int8)에서 몇 분짜리 핸즈프리 받아쓰기를 항상 끊어, 녹음 전체가
|
||||
* 버려졌다. 오디오 1초당 `perAudioSecondMs` 를 주고, 짧은 녹음은 `floorMs` 를 보장한다.
|
||||
*/
|
||||
export function transcriptionTimeoutMs(
|
||||
pcmBytes: number,
|
||||
options: { floorMs: number; perAudioSecondMs: number }
|
||||
): number {
|
||||
const seconds = Math.max(0, pcmBytes) / PCM16_MONO_16K_BYTES_PER_SECOND
|
||||
return Math.max(options.floorMs, Math.ceil(seconds * options.perAudioSecondMs))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { STTProviderConfig } from '@d3ro/core/types'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import { getCloudSyncService } from '../../CloudSyncService'
|
||||
import { createCloudSttGateway, type CloudSttGateway } from '../../cloud/cloud-credentials'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
import { pcmToWav } from '../audio-utils'
|
||||
import type { ISTTDriver } from '../types'
|
||||
|
||||
const logger = getLogger('D3ROCloudDriver')
|
||||
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
export type { CloudSttGateway }
|
||||
|
||||
interface CloudSttPayload {
|
||||
transcript?: unknown
|
||||
|
|
@ -79,7 +75,7 @@ export class D3ROCloudDriver implements ISTTDriver {
|
|||
readonly id = 'd3ro-cloud' as const
|
||||
readonly name = 'D3RO Cloud STT (Managed)'
|
||||
|
||||
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
|
||||
constructor(private readonly cloud: CloudSttGateway = createCloudSttGateway()) {}
|
||||
|
||||
async transcribe(
|
||||
audioBuffer: Buffer,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
//
|
||||
// 워밍업 실패도 여기서 기록한다 — 실패한 워밍업을 매 멈춤마다 다시 쏘지 않도록
|
||||
// 잠시 물러선다(backoff).
|
||||
//
|
||||
// 제안 요청만 보면 안 된다: 같은 모델을 쓰는 받아쓰기(기본 5m)·음성 대화(2m)가 keep_alive 를
|
||||
// 짧게 덮어쓰면 Ollama 가 먼저 내린다. 주입된 상주 포트(로컬 LLM 의 모든 요청 기록)가 더 이른
|
||||
// 만료를 알면 그것을 따른다.
|
||||
|
||||
import type { ModelResidencyPort } from '../llm/model-residency'
|
||||
|
||||
export class ModelWarmTracker {
|
||||
private _warmModel: string | null = null
|
||||
|
|
@ -15,6 +21,8 @@ export class ModelWarmTracker {
|
|||
private _failedModel: string | null = null
|
||||
private _failedAt = 0
|
||||
|
||||
constructor(private readonly _residency: ModelResidencyPort | null = null) {}
|
||||
|
||||
/** 이 모델이 `until` 까지 메모리에 남아 있다고 본다. 같은 모델의 실패 기록은 지운다. */
|
||||
noteWarm(model: string, until: number): void {
|
||||
this._warmModel = model
|
||||
|
|
@ -26,7 +34,16 @@ export class ModelWarmTracker {
|
|||
}
|
||||
|
||||
isWarm(model: string | null, now: number): boolean {
|
||||
return model !== null && model === this._warmModel && now < this._warmUntil
|
||||
if (model === null || model !== this._warmModel || now >= this._warmUntil) return false
|
||||
const residentUntil = this._residency?.residentUntil(model) ?? null
|
||||
return residentUntil === null || now < residentUntil
|
||||
}
|
||||
|
||||
/** 이 모델이 warm 이라 믿고 요청했는데 시간 초과했다 — 이미 내려간 것이다. 다음 멈춤은 워밍업부터. */
|
||||
noteCold(model: string): void {
|
||||
if (this._warmModel !== model) return
|
||||
this._warmModel = null
|
||||
this._warmUntil = 0
|
||||
}
|
||||
|
||||
/** 워밍업이 실패했다 (모델 미설치 · 서버 오류 등). */
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ import {
|
|||
} from './sync-adapters'
|
||||
import { fetchRemoteMemoTagKeys, listLocalMemoTagKeys, reconcileMemoTags } from './memo-tag-sync'
|
||||
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners } from './audio-sync'
|
||||
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings } from './settings-sync'
|
||||
import {
|
||||
SETTINGS_ROW_ID,
|
||||
applyRemoteSettings,
|
||||
fetchRemoteSettings,
|
||||
settingsNeedResyncAfterInstructionPush,
|
||||
} from './settings-sync'
|
||||
import { applyRemoteBuiltins, editedBuiltinsDifferingFrom, fetchRemoteBuiltins } from './builtin-instruction-sync'
|
||||
import { deleteOrder, upsertOrder } from './sync-registry'
|
||||
import {
|
||||
|
|
@ -50,6 +55,7 @@ import {
|
|||
type SyncRunResult,
|
||||
} from './sync-types'
|
||||
import { earliestDeletedAt, isRestoredAfter, tombstoneWindowExpired, type TombstoneRef } from './tombstone-policy'
|
||||
import { pushableEntries } from './push-gate-policy'
|
||||
|
||||
const logger = getLogger('SyncEngine')
|
||||
|
||||
|
|
@ -209,9 +215,10 @@ export class SyncEngine extends EventEmitter {
|
|||
runFullSync(): Promise<SyncRunResult> {
|
||||
return this.serialize(async () => {
|
||||
const result = emptyRunResult()
|
||||
await this.pullTombstonesBeforePush(result, true)
|
||||
await this.backfillIfNeeded()
|
||||
await this.flushInner(result, true)
|
||||
const tombstonesSynced = await this.pullTombstonesBeforePush(result, true)
|
||||
// 최초 대조는 원격 삭제가 반영된 뒤에만 한다(지운 행을 다시 올리지 않게).
|
||||
if (tombstonesSynced) await this.backfillIfNeeded()
|
||||
await this.flushInner(result, tombstonesSynced)
|
||||
await this.pullInner(result)
|
||||
return result
|
||||
})
|
||||
|
|
@ -222,9 +229,12 @@ export class SyncEngine extends EventEmitter {
|
|||
if (options.releaseParked) releaseParkedEntries()
|
||||
const result = emptyRunResult()
|
||||
const backfillPending = getSyncState(BACKFILL_FLAG) !== 'done'
|
||||
if (backfillPending) await this.pullTombstonesBeforePush(result, true)
|
||||
await this.backfillIfNeeded()
|
||||
await this.flushInner(result, backfillPending)
|
||||
let tombstonesSynced: boolean | null = null
|
||||
if (backfillPending) {
|
||||
tombstonesSynced = await this.pullTombstonesBeforePush(result, true)
|
||||
if (tombstonesSynced) await this.backfillIfNeeded()
|
||||
}
|
||||
await this.flushInner(result, tombstonesSynced)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
|
@ -347,24 +357,29 @@ export class SyncEngine extends EventEmitter {
|
|||
/**
|
||||
* push 전에 원격 삭제를 반영한다: 폰에서 지운 행의 로컬 대기 upsert가 서버에 행을 되살리지 않게
|
||||
* outbox 항목을 버리고 로컬 행을 지운다. always=false 면 대기 upsert가 있을 때만 가져온다.
|
||||
* 끝까지 반영했거나 읽을 필요가 없었으면 true, 읽기에 실패했으면 false(이번 flush는 upsert를 보내지 않는다).
|
||||
*/
|
||||
private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise<void> {
|
||||
if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return
|
||||
private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise<boolean> {
|
||||
if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return true
|
||||
const changed = new Set<SyncEntity>(result.changed)
|
||||
let synced = true
|
||||
try {
|
||||
result.deleted += await this.pullTombstones(changed)
|
||||
} catch (err) {
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
synced = false
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`tombstones: ${message}`)
|
||||
logger.warn(`Pull tombstones before push failed: ${message}`)
|
||||
logger.warn(`Pull tombstones before push failed; holding upserts until the next flush: ${message}`)
|
||||
}
|
||||
result.changed = [...changed]
|
||||
return synced
|
||||
}
|
||||
|
||||
private async flushInner(result: SyncRunResult, tombstonesPulled: boolean): Promise<void> {
|
||||
if (!tombstonesPulled) await this.pullTombstonesBeforePush(result, false)
|
||||
const due = listDueEntries(this.now())
|
||||
/** @param tombstonesSynced 호출자가 이미 push 전 tombstone을 읽었으면 그 결과, 아니면 null(여기서 읽는다) */
|
||||
private async flushInner(result: SyncRunResult, tombstonesSynced: boolean | null): Promise<void> {
|
||||
const synced = tombstonesSynced ?? (await this.pullTombstonesBeforePush(result, false))
|
||||
const due = pushableEntries(listDueEntries(this.now()), { tombstonesSynced: synced })
|
||||
if (due.length === 0) return
|
||||
const ctx = this.context()
|
||||
const byEntity = new Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>()
|
||||
|
|
@ -383,6 +398,7 @@ export class SyncEngine extends EventEmitter {
|
|||
const outcomes = await handler.push(ctx, bucket.upserts.map((e) => e.rowId))
|
||||
this.checkpoint()
|
||||
networkDown = this.settle(bucket.upserts, outcomes, result, handler.entity)
|
||||
if (handler.entity === 'custom_instructions') this.resyncSettingsIfDeferred(outcomes, byEntity)
|
||||
}
|
||||
// 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다)
|
||||
for (const handler of deleteOrder()) {
|
||||
|
|
@ -394,6 +410,21 @@ export class SyncEngine extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 push가 서버에 없던 활성 명령 때문에 활성 명령 RPC를 미뤘고, 그 명령이 방금 올라갔으면
|
||||
* 설정을 다시 대기열에 넣는다. 이번 flush에 설정 항목이 이미 있으면 그 push가 맞추므로 넣지 않는다.
|
||||
*/
|
||||
private resyncSettingsIfDeferred(
|
||||
outcomes: PushOutcome[],
|
||||
byEntity: Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>
|
||||
): void {
|
||||
if ((byEntity.get('user_settings')?.upserts.length ?? 0) > 0) return
|
||||
const pushed = outcomes.filter((o) => o.error === null).map((o) => o.id)
|
||||
if (pushed.length === 0 || !settingsNeedResyncAfterInstructionPush(pushed)) return
|
||||
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', this.now())
|
||||
logger.info('Deferred active instruction reached the server; settings re-queued')
|
||||
}
|
||||
|
||||
/** outbox를 결과대로 정리. 네트워크 계열 실패가 있었으면 true(이번 flush 중단). */
|
||||
private settle(entries: OutboxEntry[], outcomes: PushOutcome[], result: SyncRunResult, entity: SyncEntity): boolean {
|
||||
const byId = new Map(outcomes.map((o) => [o.id, o.error]))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
// src/main/services/sync/active-instruction-push-policy.ts
|
||||
// 설정 push의 "활성 명령" 하위 단계 규칙(순수 함수). IO는 settings-sync.ts / SyncEngine.ts가 맡는다.
|
||||
//
|
||||
// 활성 명령 RPC(set_active_custom_instruction)는 서버에 그 사용자 명령 행이 있어야 성공한다(없으면 P0002).
|
||||
// 아직 올라가지 않은(대기·보관 중인) 명령을 가리키면 RPC를 보내지 않고 미룬다 — 그렇지 않으면
|
||||
// user_settings 항목이 P0002로 끝없이 재시도되고, 그동안 다른 기기의 설정 pull이 막힌다.
|
||||
// 미룬 명령이 나중에 서버에 올라가면 설정을 다시 push해 활성 명령을 맞춘다.
|
||||
|
||||
import type { ActiveInstructionPush } from './settings-sync-policy'
|
||||
import type { SyncRemoteError } from './sync-types'
|
||||
|
||||
export type ActiveInstructionStep =
|
||||
/** 서버에 올릴 것이 없다 */
|
||||
| { kind: 'none' }
|
||||
/** 서버에 아직 없는 명령 — RPC를 보내지 않고, 명령이 올라간 뒤 다시 맞춘다 */
|
||||
| { kind: 'defer'; instructionId: string }
|
||||
/** RPC로 서버 활성 명령을 바꾼다(null = 해제) */
|
||||
| { kind: 'rpc'; instructionId: string | null }
|
||||
|
||||
/**
|
||||
* 활성 명령 하위 단계를 정한다.
|
||||
* @param unsyncedInstructionIds 로컬 outbox에 upsert가 남아 있는(= 서버에 없을 수 있는) 사용자 명령 id
|
||||
*/
|
||||
export function planActiveInstructionStep(
|
||||
push: ActiveInstructionPush | null,
|
||||
unsyncedInstructionIds: ReadonlySet<string>
|
||||
): ActiveInstructionStep {
|
||||
if (!push) return { kind: 'none' }
|
||||
if (push.instructionId !== null && unsyncedInstructionIds.has(push.instructionId)) {
|
||||
return { kind: 'defer', instructionId: push.instructionId }
|
||||
}
|
||||
return { kind: 'rpc', instructionId: push.instructionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 명령 RPC가 "서버에 그 명령이 없다"(P0002)로 실패했는지.
|
||||
* 이 경우 이미 저장된 설정 필드는 완료로 두고 활성 명령만 건너뛴다 — 다시 보내도 같은 결과다.
|
||||
*/
|
||||
export function isActiveInstructionMissing(error: SyncRemoteError): boolean {
|
||||
return error.code === 'P0002'
|
||||
}
|
||||
|
||||
/**
|
||||
* 방금 서버에 올라간 명령 중 미뤄 둔 활성 명령이 있으면 설정을 다시 push해야 한다.
|
||||
* @param deferredInstructionId 설정 push가 미뤄 둔 활성 명령 id('' 또는 null = 없음)
|
||||
*/
|
||||
export function shouldResyncSettingsAfterInstructionPush(
|
||||
deferredInstructionId: string | null,
|
||||
pushedInstructionIds: readonly string[]
|
||||
): boolean {
|
||||
return !!deferredInstructionId && pushedInstructionIds.includes(deferredInstructionId)
|
||||
}
|
||||
|
|
@ -56,16 +56,26 @@ function meetingAudioPath(id: string): string {
|
|||
return path.join(app.getPath('userData'), 'meeting-audio', `${id}.wav`)
|
||||
}
|
||||
|
||||
/** 로컬 녹음 파일 경로. 파일이 없으면 null */
|
||||
/**
|
||||
* 파일 전사 기록의 audioLocalPath 는 사용자가 고른 원본 미디어(동영상 포함)다 — 앱이 녹음한 파일이
|
||||
* 아니므로 '녹음 동기화'로 올리지 않는다(설정 문구도 녹음만 올린다고 안내한다). 로컬 재생용으로만 쓴다.
|
||||
*/
|
||||
const NON_RECORDING_HISTORY_MODES: ReadonlySet<string> = new Set(['file-transcription'])
|
||||
|
||||
function isSyncableHistoryRecording(row: { mode: string | null; audioLocalPath: string | null }): boolean {
|
||||
return row.audioLocalPath !== null && !NON_RECORDING_HISTORY_MODES.has(row.mode ?? '')
|
||||
}
|
||||
|
||||
/** 로컬 녹음 파일 경로. 파일이 없거나 앱 녹음이 아니면(파일 전사 원본) null */
|
||||
export function localAudioPath(owner: AudioOwner, id: string): string | null {
|
||||
let candidate: string | null
|
||||
if (owner === 'history') {
|
||||
const row = getDatabase()
|
||||
.select({ audioLocalPath: history.audioLocalPath })
|
||||
.select({ audioLocalPath: history.audioLocalPath, mode: history.mode })
|
||||
.from(history)
|
||||
.where(eq(history.id, id))
|
||||
.get()
|
||||
candidate = row?.audioLocalPath ?? null
|
||||
candidate = row && isSyncableHistoryRecording(row) ? row.audioLocalPath : null
|
||||
} else {
|
||||
candidate = meetingAudioPath(id)
|
||||
}
|
||||
|
|
@ -76,11 +86,11 @@ export function localAudioPath(owner: AudioOwner, id: string): string | null {
|
|||
export function listLocalAudioOwners(owner: AudioOwner): string[] {
|
||||
if (owner === 'history') {
|
||||
return getDatabase()
|
||||
.select({ id: history.id, audioLocalPath: history.audioLocalPath })
|
||||
.select({ id: history.id, audioLocalPath: history.audioLocalPath, mode: history.mode })
|
||||
.from(history)
|
||||
.where(isNotNull(history.audioLocalPath))
|
||||
.all()
|
||||
.filter((r) => isUuid(r.id) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath))
|
||||
.filter((r) => isUuid(r.id) && isSyncableHistoryRecording(r) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath))
|
||||
.map((r) => r.id)
|
||||
}
|
||||
return getDatabase()
|
||||
|
|
|
|||
18
apps/desktop/src/main/services/sync/push-gate-policy.ts
Normal file
18
apps/desktop/src/main/services/sync/push-gate-policy.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// src/main/services/sync/push-gate-policy.ts
|
||||
// flush가 이번에 무엇을 push해도 되는지 정하는 규칙(순수 함수).
|
||||
//
|
||||
// push 전 tombstone 읽기에 실패하면 upsert를 보내지 않는다(fail closed). 다른 기기에서 지운 행의
|
||||
// 로컬 대기 upsert가 서버에 행을 다시 만들고, 서버가 updated_at을 새로 찍으면 다음 pull이 그 행을
|
||||
// "보관본 복원"으로 오인해 삭제를 모든 기기에서 영구히 되돌리기 때문이다. 삭제는 되살릴 위험이
|
||||
// 없으므로 그대로 보낸다. 건너뛴 upsert는 outbox에 남아 다음 flush에서 다시 시도된다.
|
||||
|
||||
import type { SyncOp } from './sync-types'
|
||||
|
||||
export interface PushGate {
|
||||
/** push 전 원격 삭제(tombstone)를 끝까지 반영했는지(읽을 필요가 없었으면 true) */
|
||||
tombstonesSynced: boolean
|
||||
}
|
||||
|
||||
export function pushableEntries<T extends { op: SyncOp }>(due: readonly T[], gate: PushGate): T[] {
|
||||
return gate.tombstonesSynced ? [...due] : due.filter((entry) => entry.op !== 'upsert')
|
||||
}
|
||||
|
|
@ -5,7 +5,15 @@
|
|||
import type { LLMActionSelection, ThemeMode } from '@d3ro/core/types'
|
||||
import { configGet, configSet } from '../ConfigService'
|
||||
import { getCustomInstructionService } from '../CustomInstructionService'
|
||||
import { getLogger } from '../LoggerService'
|
||||
import {
|
||||
isActiveInstructionMissing,
|
||||
planActiveInstructionStep,
|
||||
shouldResyncSettingsAfterInstructionPush,
|
||||
type ActiveInstructionStep,
|
||||
} from './active-instruction-push-policy'
|
||||
import { isUuid, type PushContext } from './sync-adapters'
|
||||
import { getSyncState, pendingOps, setSyncState } from './sync-outbox'
|
||||
import {
|
||||
remoteToLocalPatch,
|
||||
resolveActiveInstructionPush,
|
||||
|
|
@ -23,6 +31,10 @@ export const SETTINGS_ROW_ID = 'self'
|
|||
export const SYNCED_CONFIG_KEYS = ['language', 'theme', 'defaultLLMAction', 'activeInstructionId'] as const
|
||||
|
||||
const SETTINGS_COLUMNS = 'user_id,locale,theme_mode,auto_polish_enabled,active_instruction_id,revision'
|
||||
/** 서버에 아직 없어 RPC를 미룬 활성 명령 id(sync_state, 사용자 DB별). '' = 없음 */
|
||||
const DEFERRED_ACTIVE_INSTRUCTION_KEY = 'settings:deferredActiveInstruction'
|
||||
|
||||
const logger = getLogger('SettingsSync')
|
||||
|
||||
let applyingRemote = false
|
||||
|
||||
|
|
@ -49,6 +61,25 @@ function userInstructionIds(): Set<string> {
|
|||
)
|
||||
}
|
||||
|
||||
/** outbox에 upsert가 남아 있어 서버에 없을 수 있는 사용자 명령 id */
|
||||
function unsyncedInstructionIds(): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const [id, op] of pendingOps('custom_instructions')) if (op === 'upsert') ids.add(id)
|
||||
return ids
|
||||
}
|
||||
|
||||
function rememberDeferredActiveInstruction(ctx: PushContext, instructionId: string | null): void {
|
||||
// 해제됐거나 사용자 DB가 바뀌었으면 쓰지 않는다(엔진이 곧 중단한다).
|
||||
if (ctx.isCurrent?.() === false) return
|
||||
const next = instructionId ?? ''
|
||||
if ((getSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY) ?? '') !== next) setSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY, next)
|
||||
}
|
||||
|
||||
/** 방금 서버에 올라간 명령 중 설정 push가 미뤄 둔 활성 명령이 있으면 true(설정을 다시 올려야 한다). */
|
||||
export function settingsNeedResyncAfterInstructionPush(pushedInstructionIds: readonly string[]): boolean {
|
||||
return shouldResyncSettingsAfterInstructionPush(getSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY), pushedInstructionIds)
|
||||
}
|
||||
|
||||
export async function fetchRemoteSettings(ctx: PushContext): Promise<RemoteRow | null> {
|
||||
const rows = await ctx.remote.selectWhere('user_settings', ctx.userId, [], SETTINGS_COLUMNS)
|
||||
return rows[0] ?? null
|
||||
|
|
@ -97,15 +128,35 @@ export async function pushSettings(ctx: PushContext): Promise<SyncRemoteError |
|
|||
typeof remoteActive === 'string' ? remoteActive : null,
|
||||
userInstructionIds()
|
||||
)
|
||||
if (activePush) {
|
||||
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: activePush.instructionId })
|
||||
}
|
||||
await pushActiveInstruction(ctx, planActiveInstructionStep(activePush, unsyncedInstructionIds()))
|
||||
return null
|
||||
} catch (err) {
|
||||
return toSyncRemoteError(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 명령 하위 단계. 설정 필드는 이미 저장됐으므로, 서버에 없는 명령 때문에 설정 항목 전체가
|
||||
* 재시도에 묶이지 않게 한다(묶이면 다른 기기의 설정 pull도 막힌다).
|
||||
*/
|
||||
async function pushActiveInstruction(ctx: PushContext, step: ActiveInstructionStep): Promise<void> {
|
||||
if (step.kind === 'defer') {
|
||||
rememberDeferredActiveInstruction(ctx, step.instructionId)
|
||||
logger.info('Active instruction push deferred: instruction is not on the server yet')
|
||||
return
|
||||
}
|
||||
if (step.kind === 'rpc') {
|
||||
try {
|
||||
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: step.instructionId })
|
||||
} catch (err) {
|
||||
const error = toSyncRemoteError(err)
|
||||
if (!isActiveInstructionMissing(error)) throw error
|
||||
logger.warn(`Active instruction push skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
rememberDeferredActiveInstruction(ctx, null)
|
||||
}
|
||||
|
||||
/** 원격 설정을 로컬에 반영한다. 바꾼 것이 있으면 true */
|
||||
export function applyRemoteSettings(row: RemoteRow): boolean {
|
||||
const patch = remoteToLocalPatch(row, readLocalSettings(), userInstructionIds())
|
||||
|
|
|
|||
|
|
@ -1001,13 +1001,14 @@ const knowledgeAdapter: SyncAdapter = {
|
|||
async applyRemote(row, ctx) {
|
||||
const id = row.id
|
||||
if (!isUuid(id)) return false
|
||||
const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
if (exists) return false
|
||||
// 이미 있는 문서도 청크를 받아 비교한다 — 다른 기기에서 재색인하면 행의 updated_at만 움직이고
|
||||
// 원문이 바뀐다. 같으면 RAGService가 무시하고, 다르면 청크를 바꿔 다시 임베딩한다.
|
||||
const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index')
|
||||
// 네트워크를 기다리는 사이 로그아웃·계정 전환이 있었으면 다른 사용자 DB에 쓰지 않는다.
|
||||
assertCurrent(ctx)
|
||||
// 다른 기기가 문서 행을 올리고 청크를 아직(또는 일부만) 올렸을 수 있다 — 다음 pull에서 다시 본다.
|
||||
// 일부만 받아 저장하면 이후 pull은 "이미 있음"으로 건너뛰어 잘린 문서가 영구히 남는다.
|
||||
// 청크 업로드는 행 upsert 뒤에 일어나 행의 updated_at을 다시 움직이지 않으므로, 여기서 건너뛰면
|
||||
// (새 문서든 재색인된 문서든) 완성본을 영영 놓친다. 잘린 청크로 로컬을 덮지도 않는다.
|
||||
const chunks = completeKnowledgeChunks(chunkRows, num(row.chunk_count))
|
||||
if (chunks === null) return 'deferred'
|
||||
return getRAGService().applyRemoteDocument({
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export interface OutboxEntry {
|
|||
* 로컬 변경을 기록한다. 같은 행의 이전 대기 연산은 최신 연산으로 덮는다
|
||||
* (upsert 뒤 delete면 delete만 남는다). version을 올려 진행 중인 push가
|
||||
* 새 변경을 완료 처리하지 못하게 한다.
|
||||
*
|
||||
* 단, 대기 중인 delete 는 뒤늦은 upsert 로 되돌리지 않는다 — 행 id 는 UUID 라 삭제된 행이 같은 id 로
|
||||
* 되살아나지 않는다. 예전엔 삭제 뒤 끝난 자동 제목 생성의 upsert 가 delete 를 덮어, flush 가 "로컬에 없는
|
||||
* upsert" 를 완료로 처리하며 원격 삭제가 영영 나가지 않았다.
|
||||
*/
|
||||
export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now = Date.now()): void {
|
||||
getDatabase()
|
||||
|
|
@ -34,7 +38,7 @@ export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now
|
|||
.onConflictDoUpdate({
|
||||
target: [syncOutbox.entity, syncOutbox.rowId],
|
||||
set: {
|
||||
op,
|
||||
op: op === 'upsert' ? sql`CASE WHEN ${syncOutbox.op} = 'delete' THEN 'delete' ELSE 'upsert' END` : op,
|
||||
version: sql`${syncOutbox.version} + 1`,
|
||||
queuedAt: now,
|
||||
attempts: 0,
|
||||
|
|
@ -149,6 +153,10 @@ export function setSyncState(key: string, value: string, now = Date.now()): void
|
|||
.run()
|
||||
}
|
||||
|
||||
export function deleteSyncState(key: string): void {
|
||||
getDatabase().delete(syncState).where(eq(syncState.key, key)).run()
|
||||
}
|
||||
|
||||
function toEntry(row: SyncOutboxRow): OutboxEntry {
|
||||
return {
|
||||
entity: row.entity as SyncEntity,
|
||||
|
|
|
|||
245
apps/desktop/src/main/services/voice-command-policy.ts
Normal file
245
apps/desktop/src/main/services/voice-command-policy.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// src/main/services/voice-command-policy.ts
|
||||
// 음성 단축키의 순수 정책: 기본 키워드 테이블, 키워드 매칭, 저장된 레거시 기본값 이관.
|
||||
// IO(electron-store, logger)에 의존하지 않으므로 표 형태로 단위 테스트한다.
|
||||
|
||||
import type {
|
||||
VoiceCommandRule,
|
||||
VoiceCommandKeyword,
|
||||
KeywordMatchMode
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
// ============================================================
|
||||
// 기본 키워드 (프리셋 명령어용)
|
||||
// ============================================================
|
||||
|
||||
export interface DefaultKeywordEntry {
|
||||
instructionId: string
|
||||
keywords: ReadonlyArray<VoiceCommandKeyword>
|
||||
priority: number
|
||||
}
|
||||
|
||||
function prefixKeywords(words: ReadonlyArray<string>): VoiceCommandKeyword[] {
|
||||
return words.map((keyword) => ({ keyword, matchMode: 'prefix' as const }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 키워드는 명령형(동사형)만 쓴다. '요약', '번역', '설명', 'explain' 같은
|
||||
* 맨 명사는 "요약 보고서 첨부해서 보내드립니다." 같은 일반 받아쓰기 문장의 첫 단어로
|
||||
* 흔히 나오므로, prefix로 매칭하면 받아쓴 문장이 LLM 변환 결과로 바뀌고 첫 단어가 사라진다.
|
||||
* 긴 키워드를 먼저 둔다 (같은 규칙 안에서 앞의 키워드부터 검사한다).
|
||||
*/
|
||||
export const DEFAULT_VOICE_COMMAND_KEYWORDS: ReadonlyArray<DefaultKeywordEntry> = [
|
||||
{
|
||||
instructionId: 'builtin-translate',
|
||||
keywords: prefixKeywords([
|
||||
'영어로 번역해 주세요',
|
||||
'영어로 번역해주세요',
|
||||
'영어로 번역해 줘',
|
||||
'영어로 번역해줘',
|
||||
'번역해 주세요',
|
||||
'번역해주세요',
|
||||
'번역해 줘',
|
||||
'번역해줘',
|
||||
'translate this'
|
||||
]),
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-summarize',
|
||||
keywords: prefixKeywords([
|
||||
'요약해 주세요',
|
||||
'요약해주세요',
|
||||
'요약해 줘',
|
||||
'요약해줘',
|
||||
'summarize this'
|
||||
]),
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-formal',
|
||||
keywords: prefixKeywords([
|
||||
'다듬어 주세요',
|
||||
'다듬어주세요',
|
||||
'다듬어 줘',
|
||||
'다듬어줘',
|
||||
'polish this'
|
||||
]),
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-explain-code',
|
||||
keywords: prefixKeywords([
|
||||
'설명해 주세요',
|
||||
'설명해주세요',
|
||||
'설명해 줘',
|
||||
'설명해줘',
|
||||
'explain this'
|
||||
]),
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 이전 버전이 첫 실행 때 저장한 기본 키워드. 저장소에 이 목록이 그대로 남아 있으면
|
||||
* (사용자가 손대지 않은 기본값) 새 기본값으로 교체한다.
|
||||
*/
|
||||
const LEGACY_DEFAULT_KEYWORDS: Readonly<Record<string, ReadonlyArray<string>>> = {
|
||||
'builtin-translate': ['번역해줘', '번역', '영어로', 'translate'],
|
||||
'builtin-summarize': ['요약해줘', '요약', 'summarize'],
|
||||
'builtin-formal': ['다듬어줘', '다듬기', 'polish'],
|
||||
'builtin-explain-code': ['설명해줘', '설명', 'explain']
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 키워드 매칭
|
||||
// ============================================================
|
||||
|
||||
export interface KeywordMatchResult {
|
||||
matched: boolean
|
||||
cleanedText: string
|
||||
}
|
||||
|
||||
// 공백·구두점 (따옴표/괄호 포함). 키워드 경계 판정에 쓴다.
|
||||
const WORD_BOUNDARY = /[\s,.!?;:'"()[\]{}\-/…~]/
|
||||
// 키워드를 잘라낸 자리에 남는 구분 기호. 여는/닫는 따옴표·괄호는 내용의 일부이므로 남긴다.
|
||||
const LEADING_SEPARATORS = /^[\s,.!?;:\-/…~–—]+/
|
||||
const TRAILING_SEPARATORS = /[\s,;:\-/…~–—]+$/
|
||||
// 변환할 내용이 실제로 있는지: 글자나 숫자가 하나라도 있어야 한다.
|
||||
const HAS_CONTENT = /[\p{L}\p{N}]/u
|
||||
|
||||
export function isWordBoundary(char: string): boolean {
|
||||
return WORD_BOUNDARY.test(char)
|
||||
}
|
||||
|
||||
function noMatch(trimmed: string): KeywordMatchResult {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
|
||||
function withContent(cleaned: string, trimmed: string): KeywordMatchResult {
|
||||
// 명령어만 말하고 대상 내용이 없으면 명령으로 보지 않는다 ('요약해줘.' → 원문 그대로 받아쓰기).
|
||||
if (!HAS_CONTENT.test(cleaned)) return noMatch(trimmed)
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다.
|
||||
* 키워드 앞뒤의 공백/구두점 경계를 존중하고, 키워드를 뺀 뒤 변환할 내용이 남아야 매칭으로 본다.
|
||||
*/
|
||||
export function matchKeyword(
|
||||
text: string,
|
||||
keyword: string,
|
||||
mode: KeywordMatchMode
|
||||
): KeywordMatchResult {
|
||||
const trimmed = text.trim()
|
||||
const lowerText = trimmed.toLowerCase()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
|
||||
if (lowerKeyword.length === 0 || lowerKeyword.length !== keyword.length) {
|
||||
// 빈 키워드, 또는 소문자 변환으로 길이가 바뀌는 키워드는 오프셋을 신뢰할 수 없다
|
||||
return noMatch(trimmed)
|
||||
}
|
||||
const keywordLength = lowerKeyword.length
|
||||
|
||||
switch (mode) {
|
||||
case 'prefix': {
|
||||
if (!lowerText.startsWith(lowerKeyword)) return noMatch(trimmed)
|
||||
const afterKeyword = trimmed.charAt(keywordLength)
|
||||
if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) return noMatch(trimmed)
|
||||
const cleaned = trimmed.slice(keywordLength).replace(LEADING_SEPARATORS, '')
|
||||
return withContent(cleaned, trimmed)
|
||||
}
|
||||
|
||||
case 'suffix': {
|
||||
if (!lowerText.endsWith(lowerKeyword)) return noMatch(trimmed)
|
||||
const beforeKeyword = trimmed.charAt(trimmed.length - keywordLength - 1)
|
||||
if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) return noMatch(trimmed)
|
||||
const cleaned = trimmed
|
||||
.slice(0, trimmed.length - keywordLength)
|
||||
.replace(TRAILING_SEPARATORS, '')
|
||||
return withContent(cleaned, trimmed)
|
||||
}
|
||||
|
||||
case 'contains': {
|
||||
const index = lowerText.indexOf(lowerKeyword)
|
||||
if (index === -1) return noMatch(trimmed)
|
||||
// contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거
|
||||
const before = trimmed.slice(0, index)
|
||||
const after = trimmed.slice(index + keywordLength)
|
||||
const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim()
|
||||
return withContent(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuleMatch {
|
||||
rule: VoiceCommandRule
|
||||
keyword: string
|
||||
cleanedText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 규칙을 priority 오름차순(0이 가장 높음)으로 검사해 첫 매칭을 돌려준다.
|
||||
*/
|
||||
export function findRuleMatch(
|
||||
text: string,
|
||||
rules: ReadonlyArray<VoiceCommandRule>
|
||||
): RuleMatch | null {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) return null
|
||||
|
||||
const sortedRules = rules
|
||||
.filter((r) => r.enabled && r.keywords.length > 0)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
for (const kw of rule.keywords) {
|
||||
const result = matchKeyword(trimmed, kw.keyword, kw.matchMode)
|
||||
if (result.matched) {
|
||||
return { rule, keyword: kw.keyword, cleanedText: result.cleanedText }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 기본값 생성 / 레거시 이관
|
||||
// ============================================================
|
||||
|
||||
export function buildDefaultRules(createId: () => string): VoiceCommandRule[] {
|
||||
return DEFAULT_VOICE_COMMAND_KEYWORDS.map((entry) => ({
|
||||
id: createId(),
|
||||
instructionId: entry.instructionId,
|
||||
keywords: entry.keywords.map((kw) => ({ ...kw })),
|
||||
enabled: true,
|
||||
priority: entry.priority
|
||||
}))
|
||||
}
|
||||
|
||||
function isUntouchedLegacyDefault(rule: VoiceCommandRule): boolean {
|
||||
const legacy = LEGACY_DEFAULT_KEYWORDS[rule.instructionId]
|
||||
if (!legacy || rule.keywords.length !== legacy.length) return false
|
||||
return rule.keywords.every(
|
||||
(kw, i) => kw.matchMode === 'prefix' && kw.keyword === legacy[i]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 버전이 저장한, 사용자가 손대지 않은 맨 명사 기본 키워드를 새 명령형 기본값으로 바꾼다.
|
||||
* 사용자가 IPC로 바꾼 키워드는 그대로 둔다. migrated는 교체한 규칙 수다.
|
||||
*/
|
||||
export function migrateLegacyDefaultRules(
|
||||
rules: ReadonlyArray<VoiceCommandRule>
|
||||
): { rules: VoiceCommandRule[]; migrated: number } {
|
||||
let migrated = 0
|
||||
const next = rules.map((rule) => {
|
||||
if (!isUntouchedLegacyDefault(rule)) return rule
|
||||
const entry = DEFAULT_VOICE_COMMAND_KEYWORDS.find(
|
||||
(e) => e.instructionId === rule.instructionId
|
||||
)
|
||||
if (!entry) return rule
|
||||
migrated++
|
||||
return { ...rule, keywords: entry.keywords.map((kw) => ({ ...kw })) }
|
||||
})
|
||||
return { rules: next, migrated }
|
||||
}
|
||||
|
|
@ -217,7 +217,17 @@ export function isWithinRollout(
|
|||
return rolloutBucket(deviceId, version) < percentage
|
||||
}
|
||||
|
||||
/** 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. 실패 필드는 기본값 유지. */
|
||||
/**
|
||||
* 알 수 없는 JSON을 검증·정규화해 정책으로 만든다.
|
||||
*
|
||||
* 없는 필드와 형식이 틀린 일반 필드는 기본값을 유지한다. 단 안전 필드는 값이 있는데
|
||||
* 형식이 틀리면 가장 보수적인 값으로 읽는다(fail-closed) — 예전에는 `"killSwitch": "true"`
|
||||
* 나 `"stagingPercentage": "5"` 같은 오타가 킬 스위치 꺼짐·100% 배포로 조용히 바뀌어
|
||||
* 운영자 의도와 정반대로 동작했다.
|
||||
* - killSwitch : boolean 이 아니면 true (업데이트 중단)
|
||||
* - stagingPercentage : 정수가 아니면 0 (아무에게도 배포하지 않음; 필수 업데이트는 무관)
|
||||
* 게시 전 엄격한 검증은 scripts/ci/lib/update-policy-schema.mjs 가 맡는다.
|
||||
*/
|
||||
export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return DEFAULT_UPDATE_POLICY
|
||||
const record = raw as Record<string, unknown>
|
||||
|
|
@ -244,8 +254,14 @@ export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
|
|||
}
|
||||
if (Number.isSafeInteger(record.stagingPercentage)) {
|
||||
policy.stagingPercentage = Math.min(100, Math.max(0, record.stagingPercentage as number))
|
||||
} else if (record.stagingPercentage !== undefined) {
|
||||
policy.stagingPercentage = 0
|
||||
}
|
||||
if (typeof record.killSwitch === 'boolean') {
|
||||
policy.killSwitch = record.killSwitch
|
||||
} else if (record.killSwitch !== undefined) {
|
||||
policy.killSwitch = true
|
||||
}
|
||||
if (typeof record.killSwitch === 'boolean') policy.killSwitch = record.killSwitch
|
||||
|
||||
if (record.channels && typeof record.channels === 'object' && !Array.isArray(record.channels)) {
|
||||
const channels = record.channels as Record<string, unknown>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장.
|
||||
// bootstrap과 테스트가 같은 함수를 호출한다.
|
||||
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getHistoryService } from './services/HistoryService'
|
||||
|
|
@ -19,12 +18,16 @@ export interface PersistableVoiceSession {
|
|||
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 audioPath = join(app.getPath('userData'), 'recordings', `${session.id}.wav`)
|
||||
const mode = session.mode === 'hands-free' ? 'dictation' : session.mode
|
||||
|
||||
return getHistoryService().create({
|
||||
|
|
@ -51,11 +54,12 @@ 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)
|
||||
return persistCompletedVoiceSession(session, finalText, audioPath)
|
||||
} catch (error) {
|
||||
const d3ro =
|
||||
error instanceof D3ROError
|
||||
|
|
|
|||
23
apps/desktop/src/main/windows/app-menu.ts
Normal file
23
apps/desktop/src/main/windows/app-menu.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// src/main/windows/app-menu.ts
|
||||
// 애플리케이션 메뉴 정책 — 플랫폼별로 어떤 메뉴를 둘지 결정한다.
|
||||
//
|
||||
// Win/Linux: 보더리스 창 + 커스텀 TitleBar 라 메뉴가 없어야 한다(Alt 키로 숨은 메뉴가 활성화되지 않게).
|
||||
// macOS: Cmd+C/V/X/A/Z 와 Cmd+Q 는 애플리케이션 메뉴의 role 항목을 거쳐서만 동작한다. 메뉴를 null 로
|
||||
// 지우면 입력란의 복사·붙여넣기·실행 취소와 종료 단축키가 모두 죽으므로 role 기반 최소 메뉴를 둔다.
|
||||
// role 메뉴의 라벨은 Electron/OS 기본값을 쓴다(앱 코드에 하드코딩 문구 없음).
|
||||
|
||||
import type { Menu, MenuItemConstructorOptions } from 'electron'
|
||||
|
||||
/** 플랫폼에 맞는 애플리케이션 메뉴 템플릿. null 이면 메뉴를 두지 않는다. */
|
||||
export function buildApplicationMenuTemplate(platform: NodeJS.Platform): MenuItemConstructorOptions[] | null {
|
||||
if (platform !== 'darwin') return null
|
||||
return [{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
|
||||
}
|
||||
|
||||
/** Menu 정적 API 중 이 정책이 쓰는 부분 — 테스트에서 가짜로 바꾼다 */
|
||||
export type ApplicationMenuApi = Pick<typeof Menu, 'buildFromTemplate' | 'setApplicationMenu'>
|
||||
|
||||
export function installApplicationMenu(menuApi: ApplicationMenuApi, platform: NodeJS.Platform): void {
|
||||
const template = buildApplicationMenuTemplate(platform)
|
||||
menuApi.setApplicationMenu(template ? menuApi.buildFromTemplate(template) : null)
|
||||
}
|
||||
46
apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts
Normal file
46
apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// redteam r3-4 회귀: macOS Dock 아이콘 클릭('activate')이 트레이로 숨긴 메인 창을 다시 띄운다.
|
||||
// 예전엔 'activate' 리스너가 없어 closeToTray(기본 true)로 창을 닫은 뒤 Dock 으로 돌아올 수 없었다.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Listener = (...args: unknown[]) => void
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
appListeners: new Map<string, Listener>(),
|
||||
showMainWindow: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
on: vi.fn((event: string, listener: Listener) => {
|
||||
state.appListeners.set(event, listener)
|
||||
}),
|
||||
// 부트스트랩은 이 테스트의 관심사가 아니다 — ready 를 오지 않게 둔다
|
||||
whenReady: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
setAsDefaultProtocolClient: vi.fn(),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
disableHardwareAcceleration: vi.fn(),
|
||||
commandLine: { appendSwitch: vi.fn() },
|
||||
exit: vi.fn(),
|
||||
getPath: vi.fn(() => '.'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/app-identity', () => ({ applyAppIdentity: vi.fn() }))
|
||||
vi.mock('../../src/main/bootstrap', () => ({ bootstrap: vi.fn(async () => undefined) }))
|
||||
vi.mock('../../src/main/lifecycle', () => ({ setupLifecycle: vi.fn() }))
|
||||
vi.mock('../../src/main/windows/WindowManager', () => ({ showMainWindow: state.showMainWindow }))
|
||||
vi.mock('../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe("app 'activate' (macOS Dock 클릭)", () => {
|
||||
it('메인 창을 다시 띄운다', async () => {
|
||||
await import('../../src/main/index')
|
||||
|
||||
const activate = state.appListeners.get('activate')
|
||||
expect(activate).toBeTypeOf('function')
|
||||
activate?.()
|
||||
expect(state.showMainWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
138
apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts
Normal file
138
apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// tests/main/ipc/ipc-redteam-r3-1.test.ts
|
||||
// IPC 회귀 (r3-1):
|
||||
// - caption:stop 은 사용자 자막만 멈추고 회의가 소유한 자막은 거부한다 (items 13·41)
|
||||
// - stt:setModel 은 설치된 모델만 올린다 — 미설치 모델은 설정만 저장 (item 10)
|
||||
// - 입력 수집 동의·일시정지 변경을 제안 서비스에 알린다 (item 7)
|
||||
// - realtime-token 은 주입된 자격 증명 포트로 호출한다 (item 2)
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, (event: unknown, params?: unknown) => Promise<unknown>>())
|
||||
|
||||
const caption = vi.hoisted(() => ({ stop: vi.fn(), start: vi.fn(), getState: vi.fn(() => 'active') }))
|
||||
const stt = vi.hoisted(() => ({ initialize: vi.fn(async () => undefined), isModelInstalled: vi.fn(() => false), on: vi.fn() }))
|
||||
const sttManager = vi.hoisted(() => ({ getActiveProvider: vi.fn(() => 'local'), on: vi.fn() }))
|
||||
const config = vi.hoisted(() => ({ configGet: vi.fn(), configSet: vi.fn() }))
|
||||
const telemetry = vi.hoisted(() => ({ setEnabled: vi.fn(), setPaused: vi.fn(), getState: vi.fn(() => ({})) }))
|
||||
const suggestion = vi.hoisted(() => ({ handleInputConsentChanged: vi.fn(), applyConfig: vi.fn() }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, params?: unknown) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
on: vi.fn(),
|
||||
},
|
||||
session: { defaultSession: { setDisplayMediaRequestHandler: vi.fn() } },
|
||||
desktopCapturer: { getSources: vi.fn(async () => []) },
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => caption }))
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({
|
||||
endCaptionOverlayDrag: vi.fn(),
|
||||
resetCaptionOverlayPosition: vi.fn(),
|
||||
setCaptionOverlayInteractive: vi.fn(),
|
||||
startCaptionOverlayDrag: vi.fn(),
|
||||
getMainWindow: vi.fn(() => null),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => stt }))
|
||||
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ on: vi.fn() }) }))
|
||||
vi.mock('../../../src/main/services/stt/STTManager', () => ({ getSTTManager: () => sttManager }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => config)
|
||||
vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => telemetry }))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/SuggestionService', () => ({ getSuggestionService: () => suggestion }))
|
||||
vi.mock('../../../src/main/services/VoiceConversationService', () => ({ getVoiceConversationService: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => {
|
||||
throw new Error('handlers must use the injected credentials port')
|
||||
},
|
||||
}))
|
||||
|
||||
async function invoke(channel: string, params?: unknown): Promise<{ success: boolean; error?: { code: number } }> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) throw new Error(`handler not registered: ${channel}`)
|
||||
return (await handler({}, params)) as { success: boolean; error?: { code: number } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
handlers.clear()
|
||||
})
|
||||
|
||||
describe('caption:stop 소유자 확인', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/main/ipc/caption-handlers')
|
||||
mod.registerCaptionHandlers()
|
||||
})
|
||||
|
||||
it("owner 'user' 로만 멈춘다", async () => {
|
||||
caption.stop.mockResolvedValue(null)
|
||||
expect((await invoke(IPC_CHANNELS.CAPTION.STOP)).success).toBe(true)
|
||||
expect(caption.stop).toHaveBeenCalledWith('user')
|
||||
})
|
||||
|
||||
it('회의가 소유한 자막이면 거부한다 (강제 정지하지 않음)', async () => {
|
||||
caption.stop.mockResolvedValue(false)
|
||||
const result = await invoke(IPC_CHANNELS.CAPTION.STOP)
|
||||
expect(result.success).toBe(false)
|
||||
expect(caption.stop).not.toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stt:setModel 미설치 모델', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/main/ipc/stt-handlers')
|
||||
mod.registerSTTHandlers()
|
||||
})
|
||||
|
||||
it('미설치 모델은 설정만 저장하고 사이드카에 올리지 않는다', async () => {
|
||||
stt.isModelInstalled.mockReturnValue(false)
|
||||
const result = await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'large-v3' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(config.configSet).toHaveBeenCalledWith('sttModelId', 'large-v3')
|
||||
expect(stt.initialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('설치된 모델은 바로 올린다', async () => {
|
||||
stt.isModelInstalled.mockReturnValue(true)
|
||||
await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'small' })
|
||||
expect(stt.initialize).toHaveBeenCalledWith('small')
|
||||
})
|
||||
})
|
||||
|
||||
describe('입력 수집 동의 변경', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/main/ipc/input-telemetry-handlers')
|
||||
mod.registerInputTelemetryHandlers()
|
||||
})
|
||||
|
||||
it('동의를 끄면 제안 서비스에 알린다', async () => {
|
||||
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED, { enabled: false })
|
||||
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('일시정지도 제안 서비스에 알린다', async () => {
|
||||
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED, { paused: true })
|
||||
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime-token 은 자격 증명 포트를 쓴다', () => {
|
||||
it('로그아웃 상태의 포트가 error 를 주면 실패로 돌려준다', async () => {
|
||||
const mod = await import('../../../src/main/ipc/voice-conversation-handlers')
|
||||
const invokeFn = vi.fn(async () => ({ data: null, error: { message: 'No active session' } }))
|
||||
mod.registerVoiceConversationHandlers({
|
||||
hasCredentials: () => false,
|
||||
accessToken: async () => null,
|
||||
invoke: invokeFn,
|
||||
invokeStream: async () => ({ stream: null, error: { message: 'No active session' } }),
|
||||
})
|
||||
const result = await invoke(IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, {})
|
||||
expect(result.success).toBe(false)
|
||||
expect(invokeFn).toHaveBeenCalledWith('realtime-token', {})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// tests/main/services/dictionary-file-codec.test.ts
|
||||
// 사전 파일 형식(CSV·JSON) 순수 코덱 — DictionaryService 에서 분리한 로직의 단위 테스트
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DICTIONARY_CSV_HEADER,
|
||||
parseDictionaryFile,
|
||||
serializeDictionary,
|
||||
toDictionaryImportRow
|
||||
} from '../../../src/main/services/dictionary/dictionary-file-codec'
|
||||
import type { DictionaryEntry } from '@d3ro/core/types'
|
||||
|
||||
const entry: DictionaryEntry = {
|
||||
id: 'e1',
|
||||
word: '=SUM, "quoted"',
|
||||
pronunciation: null,
|
||||
category: 'technical',
|
||||
usageCount: 3,
|
||||
lastUsedAt: null,
|
||||
createdAt: 100,
|
||||
updatedAt: 200
|
||||
}
|
||||
|
||||
describe('serializeDictionary', () => {
|
||||
it('CSV 는 BOM·헤더·CRLF 를 쓰고 수식·따옴표를 이스케이프한다', () => {
|
||||
const csv = serializeDictionary([entry], 'csv')
|
||||
expect(csv.startsWith(`\uFEFF${DICTIONARY_CSV_HEADER.join(',')}\r\n`)).toBe(true)
|
||||
expect(csv).toContain('"=SUM, ""quoted""",,technical,3,100,200\r\n')
|
||||
})
|
||||
|
||||
it('JSON 은 entries 객체로 쓴다', () => {
|
||||
expect(JSON.parse(serializeDictionary([entry], 'json'))).toEqual({ entries: [entry] })
|
||||
})
|
||||
|
||||
it('CSV 로 쓴 내용을 다시 읽으면 같은 값이 나온다', () => {
|
||||
const [record] = parseDictionaryFile(serializeDictionary([entry], 'csv'), 'csv')
|
||||
expect(record).toMatchObject({ word: entry.word, category: 'technical', usageCount: '3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDictionaryFile', () => {
|
||||
it('JSON 배열과 entries 객체를 모두 받는다', () => {
|
||||
expect(parseDictionaryFile('[{"word":"a"}, 1, null]', 'json')).toEqual([{ word: 'a' }])
|
||||
expect(parseDictionaryFile('{"entries":[{"word":"b"}]}', 'json')).toEqual([{ word: 'b' }])
|
||||
})
|
||||
|
||||
it('형식이 맞지 않으면 Error 를 던진다', () => {
|
||||
expect(() => parseDictionaryFile('{"x":1}', 'json')).toThrow(/entries/)
|
||||
expect(() => parseDictionaryFile('', 'csv')).toThrow(/empty CSV/)
|
||||
expect(() => parseDictionaryFile('term\r\nx\r\n', 'csv')).toThrow(/word/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toDictionaryImportRow', () => {
|
||||
it('필드를 정규화하고 기본값을 채운다', () => {
|
||||
expect(
|
||||
toDictionaryImportRow(
|
||||
{ word: ' AI ', category: 'bogus', usage_count: '2.7', created_at: 50 },
|
||||
999
|
||||
)
|
||||
).toEqual({
|
||||
ok: true,
|
||||
row: {
|
||||
word: 'AI',
|
||||
pronunciation: null,
|
||||
category: 'user',
|
||||
usageCount: 2,
|
||||
lastUsedAt: null,
|
||||
createdAt: 50,
|
||||
updatedAt: 50
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('빈 단어·너무 긴 값을 문제로 돌려준다', () => {
|
||||
expect(toDictionaryImportRow({ pronunciation: 'x' }, 1)).toMatchObject({
|
||||
ok: false,
|
||||
problem: { reason: 'empty-word' }
|
||||
})
|
||||
expect(toDictionaryImportRow({ word: 'w'.repeat(121) }, 1)).toMatchObject({
|
||||
ok: false,
|
||||
problem: { reason: 'too-long', field: 'word', max: 120 }
|
||||
})
|
||||
expect(toDictionaryImportRow({ word: 'ok', pronunciation: 'p'.repeat(201) }, 1)).toMatchObject({
|
||||
ok: false,
|
||||
problem: { reason: 'too-long', field: 'pronunciation', max: 200 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
// tests/main/services/dictionary-redteam-r3-26.test.ts
|
||||
// 서버 길이 제한(단어 120·발음 200)을 넘는 사전 항목은 로컬 저장·동기화 push 전에 거부돼야 한다.
|
||||
// 예전엔 로컬에 저장한 뒤 pushOne 했고, 서버가 22023 으로 거부해 아웃박스에 영구 보류됐다.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
|
||||
const readFileSync = vi.fn()
|
||||
const pushOne = vi.fn(async () => undefined)
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: { writeFileSync: vi.fn(), readFileSync },
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/tmp/d3ro' },
|
||||
dialog: { showSaveDialog: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null }))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => ({ pushOne, pushDelete: vi.fn() })
|
||||
}))
|
||||
|
||||
function chain(result: { all?: unknown[]; get?: unknown }): Record<string, unknown> {
|
||||
const builder: Record<string, unknown> = {}
|
||||
builder.from = vi.fn(() => builder)
|
||||
builder.where = vi.fn(() => builder)
|
||||
builder.all = vi.fn(() => result.all ?? [])
|
||||
builder.get = vi.fn(() => result.get)
|
||||
return builder
|
||||
}
|
||||
|
||||
const existingRow = {
|
||||
id: 'row-1',
|
||||
word: 'AI',
|
||||
pronunciation: null,
|
||||
category: 'user',
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const insertValues = vi.fn((_values: Record<string, unknown>) => ({
|
||||
run: vi.fn(() => ({ changes: 1 }))
|
||||
}))
|
||||
const updateSet = vi.fn(() => ({ where: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) }))
|
||||
|
||||
const txStub = {
|
||||
select: vi.fn(() => chain({ get: undefined })),
|
||||
insert: vi.fn(() => ({ values: insertValues }))
|
||||
}
|
||||
|
||||
const mockDb = {
|
||||
select: vi.fn(() => chain({ all: [], get: existingRow })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: updateSet })),
|
||||
transaction: vi.fn((fn: (tx: typeof txStub) => void) => fn(txStub))
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb }))
|
||||
|
||||
type ServiceModule = typeof import('../../../src/main/services/DictionaryService')
|
||||
let mod: ServiceModule
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mod = await import('../../../src/main/services/DictionaryService')
|
||||
})
|
||||
|
||||
interface CapturedError {
|
||||
name: string
|
||||
message: string
|
||||
code: unknown
|
||||
details: unknown
|
||||
}
|
||||
|
||||
// vi.resetModules() 로 서비스가 다른 D3ROError 클래스 인스턴스를 쓰므로 instanceof 대신 모양으로 확인한다
|
||||
function captureError(fn: () => unknown): CapturedError {
|
||||
try {
|
||||
fn()
|
||||
} catch (err) {
|
||||
const captured = err as CapturedError
|
||||
expect(captured.name).toBe('D3ROError')
|
||||
return captured
|
||||
}
|
||||
throw new Error('expected a D3ROError')
|
||||
}
|
||||
|
||||
describe('DictionaryService.add — 서버 길이 제한', () => {
|
||||
it('121자 단어는 저장·push 없이 field/max 를 담아 거부한다', () => {
|
||||
const err = captureError(() => mod.getDictionaryService().add({ word: 'w'.repeat(121) }))
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.details).toMatchObject({ field: 'word', max: 120 })
|
||||
expect(mockDb.insert).not.toHaveBeenCalled()
|
||||
expect(pushOne).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('201자 발음은 저장·push 없이 거부한다', () => {
|
||||
const err = captureError(() =>
|
||||
mod.getDictionaryService().add({ word: 'ok', pronunciation: 'p'.repeat(201) })
|
||||
)
|
||||
expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 })
|
||||
expect(mockDb.insert).not.toHaveBeenCalled()
|
||||
expect(pushOne).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('제한 이내 항목은 그대로 저장하고 push 한다', () => {
|
||||
const entry = mod.getDictionaryService().add({ word: 'w'.repeat(120), pronunciation: ' p ' })
|
||||
expect(entry.word).toBe('w'.repeat(120))
|
||||
expect(entry.pronunciation).toBe('p')
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1)
|
||||
expect(pushOne).toHaveBeenCalledWith('dictionary', entry.id)
|
||||
})
|
||||
|
||||
it('빈 단어는 기존과 같은 메시지로 거부한다', () => {
|
||||
const err = captureError(() => mod.getDictionaryService().add({ word: ' ' }))
|
||||
expect(err.message).toBe('Dictionary word is empty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DictionaryService.update — 서버 길이 제한', () => {
|
||||
it('발음을 201자로 바꾸는 편집은 쓰기·push 없이 거부한다', () => {
|
||||
const err = captureError(() =>
|
||||
mod.getDictionaryService().update({ id: 'row-1', pronunciation: 'p'.repeat(201) })
|
||||
)
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 })
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
expect(pushOne).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('단어를 121자로 바꾸는 편집을 거부한다', () => {
|
||||
const err = captureError(() =>
|
||||
mod.getDictionaryService().update({ id: 'row-1', word: 'w'.repeat(121) })
|
||||
)
|
||||
expect(err.details).toMatchObject({ field: 'word', max: 120 })
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DictionaryService.importDictionary — 서버 길이 제한', () => {
|
||||
it('너무 긴 단어·발음 행은 오류로 세고 저장·push 하지 않는다', () => {
|
||||
readFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ word: 'w'.repeat(121) },
|
||||
{ word: 'long-pron', pronunciation: 'p'.repeat(201) },
|
||||
{ word: 'ok', pronunciation: '오케이' }
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = mod
|
||||
.getDictionaryService()
|
||||
.importDictionary({ filePath: '/in/d.json', format: 'json' })
|
||||
expect(result).toEqual({ imported: 1, skipped: 0, errors: 2 })
|
||||
expect(insertValues).toHaveBeenCalledTimes(1)
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ word: 'ok', pronunciation: '오케이', category: 'user' })
|
||||
)
|
||||
expect(pushOne).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('CSV 의 너무 긴 행도 거부한다', () => {
|
||||
readFileSync.mockReturnValue(`word,pronunciation\r\n${'x'.repeat(121)},\r\nfine,\r\n`)
|
||||
const result = mod
|
||||
.getDictionaryService()
|
||||
.importDictionary({ filePath: '/in/d.csv', format: 'csv' })
|
||||
expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// tests/main/services/keybinding-altgr-redteam-r3-8.test.ts
|
||||
// 레드팀 r3-8 회귀 테스트 (데스크톱 어댑터) — AltGr 배열.
|
||||
//
|
||||
// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 time)으로 보내고, libuiohook 은 그
|
||||
// RAlt 눌림에 ctrlKey 를 싣는다. 어댑터가 그대로 옮기면 '@'(AltGr+Q) 마다 'command' 가 발동하고
|
||||
// 기본 dictation(RightAlt 단독)은 발동하지 않는다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||
import type { UiohookKeyboardEvent } from 'uiohook-napi'
|
||||
import { ALTGR_CHORD_GRACE_MS } from '@d3ro/core/keybinding-runtime'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import { getKeyBindingService } from '../../../src/main/services/KeyBindingService'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
function restorePlatform(): void {
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
|
||||
type KeyHandler = (e: UiohookKeyboardEvent) => void
|
||||
|
||||
function handlerOf(event: 'keydown' | 'keyup'): KeyHandler {
|
||||
const calls = vi.mocked(uIOhook.on).mock.calls.filter((c) => c[0] === event)
|
||||
const last = calls[calls.length - 1]
|
||||
if (!last) throw new Error(`no ${event} handler`)
|
||||
return last[1] as KeyHandler
|
||||
}
|
||||
|
||||
interface Mods {
|
||||
ctrl?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
function keyEvent(keycode: number, time: number, mods: Mods = {}): UiohookKeyboardEvent {
|
||||
return {
|
||||
keycode,
|
||||
time,
|
||||
ctrlKey: mods.ctrl ?? false,
|
||||
altKey: mods.alt ?? false,
|
||||
shiftKey: false,
|
||||
metaKey: false
|
||||
} as unknown as UiohookKeyboardEvent
|
||||
}
|
||||
|
||||
describe('KeyBindingService — AltGr 배열 (Windows)', () => {
|
||||
const events: string[] = []
|
||||
const listener = (p: KeyBindingTriggerPayload): void => {
|
||||
events.push(`${p.actionId}:${p.type}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
setPlatform('win32')
|
||||
initInMemoryConfig()
|
||||
vi.mocked(uIOhook.on).mockClear()
|
||||
events.length = 0
|
||||
const service = getKeyBindingService()
|
||||
service.loadFromConfig()
|
||||
service.start()
|
||||
service.on('triggered', listener)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const service = getKeyBindingService()
|
||||
service.off('triggered', listener)
|
||||
service.stop()
|
||||
resetInMemoryConfig()
|
||||
restorePlatform()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function altGrDown(time: number): void {
|
||||
handlerOf('keydown')(keyEvent(UiohookKey.Ctrl, time, { ctrl: true }))
|
||||
handlerOf('keydown')(keyEvent(UiohookKey.AltRight, time, { ctrl: true, alt: true }))
|
||||
}
|
||||
|
||||
function altGrUp(): void {
|
||||
handlerOf('keyup')(keyEvent(UiohookKey.Ctrl, 0, { alt: true }))
|
||||
handlerOf('keyup')(keyEvent(UiohookKey.AltRight, 0))
|
||||
}
|
||||
|
||||
it("AltGr+Q('@') 는 어떤 트리거도 내보내지 않는다", () => {
|
||||
altGrDown(5_000)
|
||||
handlerOf('keydown')(keyEvent(UiohookKey.Q, 5_040, { ctrl: true, alt: true }))
|
||||
handlerOf('keyup')(keyEvent(UiohookKey.Q, 5_080, { ctrl: true, alt: true }))
|
||||
altGrUp()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('AltGr 를 누르고 있으면 기본 dictation 이 발동한다 (command 가 아니다)', () => {
|
||||
altGrDown(6_000)
|
||||
vi.advanceTimersByTime(ALTGR_CHORD_GRACE_MS)
|
||||
expect(events).toEqual(['dictation:pressed'])
|
||||
altGrUp()
|
||||
expect(events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// tests/main/services/meeting-condense-redteam-r3-3.test.ts
|
||||
// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의)도 map 조각이 프록시 한도 안에 들어가야 한다.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
|
||||
|
||||
const PROXY_MESSAGE_LIMIT = 8_000
|
||||
|
||||
/** CaptionService fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 */
|
||||
const singleLine = Array.from({ length: 900 }, (_, i) => `${i}번째 발언에서 일정과 담당자를 정리했습니다.`).join(' ')
|
||||
|
||||
describe('condenseTranscriptToBudget — 한 줄짜리 긴 전사', () => {
|
||||
it('한 줄 20,000자 전사를 여러 조각으로 나눠 줄이고, 조각 입력은 모두 한도 안이다', async () => {
|
||||
expect(singleLine.includes('\n')).toBe(false)
|
||||
expect(singleLine.length).toBeGreaterThan(20_000)
|
||||
const inputs: string[] = []
|
||||
const llm = {
|
||||
generate: vi.fn(async (text: string) => {
|
||||
inputs.push(text)
|
||||
return { text: `요약 ${inputs.length}` }
|
||||
}),
|
||||
}
|
||||
const result = await condenseTranscriptToBudget(llm, singleLine, PROXY_MESSAGE_LIMIT)
|
||||
expect(result.condensed).toBe(true)
|
||||
expect(result.text.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT)
|
||||
expect(inputs.length).toBeGreaterThan(1)
|
||||
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
// tests/main/services/runtime-provisioner-redteam-r3-5.test.ts
|
||||
// 런타임 인덱스의 플랫폼 구분과 "feed 가 앱보다 늦은" 경우의 회귀 테스트.
|
||||
//
|
||||
// 회귀 대상:
|
||||
// 1) runtime.json 에 platform/arch 가 없어 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고
|
||||
// 검증에서 떨어진 뒤, ensure 할 때마다 ~160MB 를 다시 받던 문제
|
||||
// 2) feed 버전이 앱의 RUNTIME_MIN_VERSION 보다 낮으면 설치된 엔진까지 버리고 로컬 STT 가
|
||||
// 완전히 멈추던 문제
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
RuntimeIndexRejectedError,
|
||||
isRuntimeIndexRejected,
|
||||
parseRuntimeIndex,
|
||||
} from '../../../src/main/services/runtime/runtime-index'
|
||||
import type { RuntimeFetch, RuntimeFetchResponse } from '../../../src/main/services/runtime/download-part'
|
||||
import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner'
|
||||
|
||||
const FEED = 'https://feed.test/runtime-latest'
|
||||
const SHA = 'c'.repeat(64)
|
||||
const PART = 'd3ro-runtime-sidecar.tar.gz.001'
|
||||
|
||||
function index(version: string, target?: { platform: string; arch: string }): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
...(target ?? {}),
|
||||
components: {
|
||||
sidecar: {
|
||||
archive: 'd3ro-runtime-sidecar.tar.gz',
|
||||
sha256: SHA,
|
||||
totalSize: 10,
|
||||
parts: [{ name: PART, size: 10, sha256: SHA, url: `${FEED}/${PART}` }],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function feed(body: Record<string, unknown>): { fetchImpl: RuntimeFetch; calls: string[] } {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: RuntimeFetch = async (url) => {
|
||||
calls.push(url)
|
||||
if (url === `${FEED}/runtime.json`) {
|
||||
return new Response(JSON.stringify(body), { status: 200 }) as RuntimeFetchResponse
|
||||
}
|
||||
return new Response(new Uint8Array(10), { status: 200 }) as RuntimeFetchResponse
|
||||
}
|
||||
return { fetchImpl, calls }
|
||||
}
|
||||
|
||||
let workDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r3-5-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function provisioner(
|
||||
fetchImpl: RuntimeFetch,
|
||||
platform: NodeJS.Platform = 'win32',
|
||||
arch = 'x64',
|
||||
): RuntimeProvisioner {
|
||||
return new RuntimeProvisioner({
|
||||
userDataDir: () => workDir,
|
||||
fetchImpl,
|
||||
feedUrl: FEED,
|
||||
platform,
|
||||
arch,
|
||||
stallTimeoutMs: 1000,
|
||||
partAttempts: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/** 예전에 받아 둔 엔진이 설치돼 있는 상태 (마커 버전 지정) */
|
||||
function installOldSidecar(p: RuntimeProvisioner, markerVersion: string): void {
|
||||
const dir = p.componentDir('sidecar')
|
||||
mkdirSync(join(dir, '_internal'), { recursive: true })
|
||||
writeFileSync(p.binaryPath('sidecar'), 'old engine')
|
||||
writeFileSync(join(dir, '.runtime-version'), markerVersion)
|
||||
}
|
||||
|
||||
describe('parseRuntimeIndex — 플랫폼 대상', () => {
|
||||
const WIN = { platform: 'win32', arch: 'x64' }
|
||||
|
||||
it('인덱스가 밝힌 플랫폼과 앱 플랫폼이 다르면 platform-mismatch 로 거부한다', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' })
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RuntimeIndexRejectedError)
|
||||
expect(isRuntimeIndexRejected(caught, 'platform-mismatch')).toBe(true)
|
||||
expect((caught as Error).message).toMatch(/darwin-arm64/)
|
||||
})
|
||||
|
||||
it('아키텍처만 달라도 거부한다', () => {
|
||||
expect(() =>
|
||||
parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'win32', arch: 'arm64' }),
|
||||
).toThrow(RuntimeIndexRejectedError)
|
||||
})
|
||||
|
||||
it('같은 플랫폼이면 통과하고, 대상을 넘기지 않으면 검사하지 않는다', () => {
|
||||
expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', WIN).version).toBe('1.9.0')
|
||||
expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0').version).toBe('1.9.0')
|
||||
})
|
||||
|
||||
it('platform/arch 가 없는 예전 인덱스는 그대로 읽는다 (하위 호환)', () => {
|
||||
expect(parseRuntimeIndex(index('1.9.0'), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }).version).toBe(
|
||||
'1.9.0',
|
||||
)
|
||||
})
|
||||
|
||||
it('platform/arch 형식이 깨졌으면 형식 오류로 거부한다', () => {
|
||||
const broken = { ...index('1.9.0'), platform: 'win32', arch: 7 }
|
||||
let caught: unknown
|
||||
try {
|
||||
parseRuntimeIndex(broken, 'sidecar', '1.7.0', WIN)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect((caught as Error).message).toMatch(/platform\/arch/)
|
||||
expect(isRuntimeIndexRejected(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('최소 버전 미만 feed 는 feed-outdated 로 구분된다', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
parseRuntimeIndex(index('1.6.0', WIN), 'sidecar', '1.7.0', WIN)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(isRuntimeIndexRejected(caught, 'feed-outdated')).toBe(true)
|
||||
expect((caught as Error).message).toMatch(/최소 요구 버전/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeProvisioner — 다른 플랫폼 feed', () => {
|
||||
it('macOS 앱은 Windows 런타임 인덱스를 보면 부품을 받기 전에 실패한다', async () => {
|
||||
const f = feed(index('1.9.0', { platform: 'win32', arch: 'x64' }))
|
||||
const p = provisioner(f.fetchImpl, 'darwin', 'arm64')
|
||||
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(/이 플랫폼\(darwin-arm64\)용 런타임이 feed에 없습니다/)
|
||||
expect(f.calls).toEqual([`${FEED}/runtime.json`])
|
||||
expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false)
|
||||
|
||||
// 다시 불러도 인덱스만 읽는다 — 대용량 재다운로드 루프가 없다
|
||||
f.calls.length = 0
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(RuntimeIndexRejectedError)
|
||||
expect(f.calls).toEqual([`${FEED}/runtime.json`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeProvisioner — feed 가 앱의 최소 버전보다 낮을 때', () => {
|
||||
it('설치된 (낡은) 엔진이 있으면 그 경로를 돌려주고 부품을 받지 않는다', async () => {
|
||||
const f = feed(index('1.6.0'))
|
||||
const p = provisioner(f.fetchImpl)
|
||||
installOldSidecar(p, '1.6.0')
|
||||
|
||||
await expect(p.ensure('sidecar')).resolves.toBe(p.binaryPath('sidecar'))
|
||||
expect(f.calls).toEqual([`${FEED}/runtime.json`])
|
||||
expect(p.isInstalled('sidecar')).toBe(true)
|
||||
})
|
||||
|
||||
it('"다시 설치"(force)는 조용히 넘어가지 않고 실패를 알린다', async () => {
|
||||
const f = feed(index('1.6.0'))
|
||||
const p = provisioner(f.fetchImpl)
|
||||
installOldSidecar(p, '1.6.0')
|
||||
|
||||
await expect(p.ensure('sidecar', { force: true })).rejects.toThrow(/최소 요구 버전/)
|
||||
expect(p.isInstalled('sidecar')).toBe(true)
|
||||
})
|
||||
|
||||
it('설치된 엔진이 없으면 실패한다', async () => {
|
||||
const f = feed(index('1.6.0'))
|
||||
await expect(provisioner(f.fetchImpl).ensure('sidecar')).rejects.toThrow(/최소 요구 버전/)
|
||||
})
|
||||
|
||||
it('다른 이유의 실패(인덱스 404)는 설치된 엔진으로 덮지 않는다', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: RuntimeFetch = async (url) => {
|
||||
calls.push(url)
|
||||
return new Response(null, { status: 404 }) as RuntimeFetchResponse
|
||||
}
|
||||
const p = provisioner(fetchImpl)
|
||||
installOldSidecar(p, '1.6.0')
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(/HTTP 404/)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// tests/main/services/stt-timeout-redteam-r3-1.test.ts
|
||||
// 최종 전사 응답 기한은 오디오 길이에 비례한다 (item 9) — 고정 120초가 몇 분짜리 핸즈프리 녹음을 버렸다.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { transcriptionTimeoutMs, PCM16_MONO_16K_BYTES_PER_SECOND } from '../../../src/main/services/stt/audio-utils'
|
||||
|
||||
describe('transcriptionTimeoutMs', () => {
|
||||
const options = { floorMs: 120_000, perAudioSecondMs: 3000 }
|
||||
|
||||
it('짧은 녹음은 하한(120초)을 쓴다', () => {
|
||||
expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 5, options)).toBe(120_000)
|
||||
})
|
||||
|
||||
it('10분 녹음은 오디오 길이에 비례해 기다린다', () => {
|
||||
expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 600, options)).toBe(1_800_000)
|
||||
})
|
||||
|
||||
it('음수·0 바이트는 하한', () => {
|
||||
expect(transcriptionTimeoutMs(0, options)).toBe(120_000)
|
||||
expect(transcriptionTimeoutMs(-5, options)).toBe(120_000)
|
||||
})
|
||||
})
|
||||
317
apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts
Normal file
317
apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// tests/main/services/suggestion-redteam-r3-1.test.ts
|
||||
// 제안 서비스 회귀 (r3-1):
|
||||
// - 케어렛을 모르는 문서 중간 편집 뒤에는 수락하지 않는다
|
||||
// - 다른 기능의 짧은 keep_alive 로 모델이 내려갔으면 warm 이 아니다 / 시간 초과면 콜드로 본다
|
||||
// - 입력 수집 동의가 없으면 모델을 데우지 않고, 철회하면 세션·보관 문맥을 버린다
|
||||
// - 한 세션은 한 이력 행이다 (채우기 후보는 행을 늘리지 않고, 수락은 세션 행에 기록)
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PersonalPhrase } from '@d3ro/core/input-intelligence'
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
suggestionEnabled: true as boolean,
|
||||
suggestionModelId: 'model-a' as string | null,
|
||||
llmModelId: 'model-a' as string | null,
|
||||
inputExcludedApps: [] as string[],
|
||||
suggestionTriggerDelayMs: 600,
|
||||
suggestionMinPrefixChars: 8,
|
||||
suggestionMaxRequestsPerMinute: 12,
|
||||
suggestionDailyBudget: 500,
|
||||
suggestionRequestTimeoutMs: 8000,
|
||||
inputLearnTypedText: false as boolean,
|
||||
inputTelemetryEnabled: false as boolean,
|
||||
inputTelemetryPaused: false as boolean,
|
||||
suggestionOverlayInteractive: true,
|
||||
insertMethod: 'clipboard'
|
||||
}))
|
||||
const harness = vi.hoisted(() => ({
|
||||
isAvailable: vi.fn(() => true),
|
||||
streamGenerate: vi.fn(),
|
||||
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
|
||||
phrases: [] as PersonalPhrase[]
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: keyof typeof config) => config[key]),
|
||||
configSet: vi.fn((key: keyof typeof config, value: never) => {
|
||||
config[key] = value
|
||||
})
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
|
||||
getInputTelemetryService: () => ({ listPhrases: () => harness.phrases })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
|
||||
getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => ({ insertText: harness.insertText })
|
||||
}))
|
||||
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
|
||||
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
|
||||
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
|
||||
|
||||
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
|
||||
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
|
||||
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
|
||||
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
|
||||
|
||||
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
|
||||
const SUGGESTION = '다음 단계도 확인하겠습니다.'
|
||||
|
||||
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
|
||||
const prefix = overrides.prefix ?? PREFIX
|
||||
return {
|
||||
prefix,
|
||||
fullText: prefix,
|
||||
caretOffset: prefix.length,
|
||||
anchor: { x: 100, y: 100, width: 2, height: 20 },
|
||||
anchorKind: 'caret',
|
||||
isPassword: false,
|
||||
isEditable: true,
|
||||
isComposing: false,
|
||||
hasSelection: false,
|
||||
available: true,
|
||||
appName: 'Slack.exe',
|
||||
windowTitle: 'general',
|
||||
windowHandle: 101,
|
||||
idleMs: 2000,
|
||||
capturedAt: Date.now(),
|
||||
editedSinceFocus: true,
|
||||
typedRecently: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
interface Fakes {
|
||||
deps: SuggestionServiceDeps
|
||||
record: ReturnType<typeof vi.fn>
|
||||
markAccepted: ReturnType<typeof vi.fn>
|
||||
expectInsert: ReturnType<typeof vi.fn>
|
||||
recordAccepted: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function fakes(): Fakes {
|
||||
const record = vi.fn()
|
||||
const markAccepted = vi.fn()
|
||||
const expectInsert = vi.fn(() => vi.fn())
|
||||
const recordAccepted = vi.fn()
|
||||
return {
|
||||
record,
|
||||
markAccepted,
|
||||
expectInsert,
|
||||
recordAccepted,
|
||||
deps: {
|
||||
repository: { record, markAccepted, list: vi.fn(() => []) },
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
foreground: { currentWindowHandle: () => 101 },
|
||||
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamOf(text: string): AsyncGenerator<string> {
|
||||
return (async function* () {
|
||||
yield text
|
||||
})()
|
||||
}
|
||||
|
||||
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
|
||||
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
|
||||
return (async function* () {
|
||||
const text = await new Promise<string>((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
void gate.then(resolve)
|
||||
})
|
||||
yield text
|
||||
})()
|
||||
}
|
||||
|
||||
interface Internal {
|
||||
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
|
||||
_lastContext: TypingContext | null
|
||||
_inFlight: boolean
|
||||
_warmUpPromise: Promise<void> | null
|
||||
}
|
||||
|
||||
let services: SuggestionService[] = []
|
||||
function create(deps: SuggestionServiceDeps, warm = true): SuggestionService {
|
||||
if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000)
|
||||
const service = new SuggestionService(deps)
|
||||
services.push(service)
|
||||
return service
|
||||
}
|
||||
|
||||
|
||||
import { ModelResidencyLedger, parseKeepAliveMs } from '../../../src/main/services/llm/model-residency'
|
||||
|
||||
beforeEach(() => {
|
||||
config.suggestionEnabled = true
|
||||
config.suggestionModelId = 'model-a'
|
||||
config.llmModelId = 'model-a'
|
||||
config.suggestionRequestTimeoutMs = 8000
|
||||
config.inputLearnTypedText = false
|
||||
config.inputTelemetryEnabled = false
|
||||
config.inputTelemetryPaused = false
|
||||
harness.isAvailable.mockReset()
|
||||
harness.isAvailable.mockReturnValue(true)
|
||||
harness.streamGenerate.mockReset()
|
||||
harness.insertText.mockClear()
|
||||
harness.phrases = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services) service.dispose()
|
||||
services = []
|
||||
})
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 8; i += 1) await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('케어렛을 모르는 필드 (item 3)', () => {
|
||||
it('문서 중간 편집 뒤(caretReliable=false)에는 표시 중인 후보를 수락하지 않는다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
|
||||
expect(service.isVisible).toBe(true)
|
||||
|
||||
const internal = service as unknown as Internal
|
||||
internal._lastContext = typingContext({ caretOffset: null, caretReliable: false })
|
||||
const result = await service.accept()
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'caret-unknown' })
|
||||
expect(harness.insertText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('케어렛을 모르는 문맥이 오면 세션을 닫는다', async () => {
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
|
||||
service.handleTypingContext(typingContext({ caretOffset: null, caretReliable: false }))
|
||||
expect(service.isVisible).toBe(false)
|
||||
expect(service.getState().lastSkipReason).toBe('caret-unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('모델 상주 추정 (item 4)', () => {
|
||||
it('keep_alive 문자열을 해석한다', () => {
|
||||
expect(parseKeepAliveMs('10m')).toBe(600_000)
|
||||
expect(parseKeepAliveMs('2m')).toBe(120_000)
|
||||
expect(parseKeepAliveMs(undefined)).toBe(300_000)
|
||||
expect(parseKeepAliveMs('1h30m')).toBe(5_400_000)
|
||||
})
|
||||
|
||||
it('같은 모델을 더 짧은 keep_alive 로 쓴 요청이 있으면 그 만료 뒤에는 warm 이 아니다', () => {
|
||||
const ledger = new ModelResidencyLedger()
|
||||
const tracker = new ModelWarmTracker(ledger)
|
||||
const t0 = 1_000_000
|
||||
ledger.noteRequest('model-a', '10m', t0)
|
||||
tracker.noteWarm('model-a', t0 + 570_000)
|
||||
// 1분 뒤 음성 대화가 같은 모델을 keep_alive 2m 로 사용
|
||||
ledger.noteRequest('model-a', '2m', t0 + 60_000)
|
||||
expect(tracker.isWarm('model-a', t0 + 120_000)).toBe(true)
|
||||
expect(tracker.isWarm('model-a', t0 + 6 * 60_000)).toBe(false)
|
||||
})
|
||||
|
||||
it('warm 이라 믿은 요청이 시간 초과하면 콜드로 본다 (다음 멈춤은 워밍업)', async () => {
|
||||
config.suggestionRequestTimeoutMs = 20
|
||||
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
|
||||
gatedStream(options.signal, new Promise<string>(() => undefined))
|
||||
)
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(true)
|
||||
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
|
||||
expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('입력 수집 동의 (item 7)', () => {
|
||||
it('동의 없이 제안을 켜면 모델을 데우지 않는다', async () => {
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps, false)
|
||||
service.setEnabled(true)
|
||||
await flush()
|
||||
expect(harness.streamGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('동의를 철회하면 떠 있는 세션을 닫고 보관 문맥을 버린다 (수동 요청이 옛 접두로 생성하지 않음)', async () => {
|
||||
config.inputTelemetryEnabled = true
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
|
||||
const fake = fakes()
|
||||
const service = create(fake.deps)
|
||||
service.handleTypingContext(typingContext())
|
||||
await flush()
|
||||
expect(service.isVisible).toBe(true)
|
||||
|
||||
config.inputTelemetryEnabled = false
|
||||
harness.streamGenerate.mockClear()
|
||||
service.handleInputConsentChanged()
|
||||
|
||||
expect(service.isVisible).toBe(false)
|
||||
expect(await service.requestNow()).toEqual({ ok: false, reason: 'not-editable' })
|
||||
expect(harness.streamGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('제안 이력은 세션당 한 행 (item 8)', () => {
|
||||
function sessionRepository() {
|
||||
return {
|
||||
record: vi.fn(() => 'row-1'),
|
||||
updateSessionCandidates: vi.fn(),
|
||||
markSessionAccepted: vi.fn(),
|
||||
markAccepted: vi.fn(),
|
||||
list: vi.fn(() => [])
|
||||
}
|
||||
}
|
||||
|
||||
it('채우기 후보는 새 행(지연 0)을 만들지 않고 세션 행의 후보 수만 갱신한다', async () => {
|
||||
const outputs = ['첫 번째 후보 문장입니다.', '두 번째 후보 문장입니다.', '세 번째 후보 문장입니다.']
|
||||
let call = 0
|
||||
harness.streamGenerate.mockImplementation(() => streamOf(outputs[Math.min(call++, outputs.length - 1)]))
|
||||
const fake = fakes()
|
||||
const repository = sessionRepository()
|
||||
fake.deps.repository = repository
|
||||
const service = create(fake.deps)
|
||||
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
|
||||
await flush()
|
||||
|
||||
expect(service.getState().candidates.length).toBeGreaterThanOrEqual(2)
|
||||
expect(repository.record).toHaveBeenCalledTimes(1)
|
||||
expect(repository.updateSessionCandidates).toHaveBeenCalledWith('row-1', expect.any(Number))
|
||||
})
|
||||
|
||||
it('로컬 기억 세션에서 2번째 후보를 수락하면 그 세션 행에 기록한다', async () => {
|
||||
config.suggestionModelId = null
|
||||
config.llmModelId = null
|
||||
harness.phrases = [
|
||||
{ id: 'p1', phrase: '다음 단계도 확인하겠습니다.', count: 3, score: 3, lastUsedAt: Date.now(), appName: null, source: 'typed' },
|
||||
{ id: 'p2', phrase: '회의록은 내일 공유드리겠습니다.', count: 2, score: 2, lastUsedAt: Date.now(), appName: null, source: 'typed' }
|
||||
] as unknown as typeof harness.phrases
|
||||
const fake = fakes()
|
||||
const repository = sessionRepository()
|
||||
fake.deps.repository = repository
|
||||
const service = create(fake.deps, false)
|
||||
const context = typingContext({ prefix: '오늘 회의 정리했습니다.' })
|
||||
const internal = service as unknown as Internal
|
||||
internal._lastContext = context
|
||||
await internal._generate(context.prefix, context, 3, 160)
|
||||
const state = service.getState()
|
||||
expect(state.candidates.length).toBeGreaterThanOrEqual(2)
|
||||
expect(repository.record).toHaveBeenCalledTimes(1)
|
||||
|
||||
const second = state.candidates[1].text
|
||||
expect(await service.accept(1)).toEqual({ ok: true })
|
||||
expect(repository.markSessionAccepted).toHaveBeenCalledWith('row-1', second)
|
||||
expect(repository.markAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import type { VoiceCommandRule } from '@d3ro/core/types'
|
||||
import {
|
||||
DEFAULT_VOICE_COMMAND_KEYWORDS,
|
||||
buildDefaultRules,
|
||||
findRuleMatch,
|
||||
matchKeyword,
|
||||
migrateLegacyDefaultRules
|
||||
} from '../../../src/main/services/voice-command-policy'
|
||||
import {
|
||||
createVoiceCommandService,
|
||||
type VoiceCommandStorePort
|
||||
} from '../../../src/main/services/VoiceCommandService'
|
||||
|
||||
function memoryStore(
|
||||
initial: { rules?: VoiceCommandRule[]; enabled?: boolean } = {}
|
||||
): VoiceCommandStorePort & { saved: VoiceCommandRule[] | undefined } {
|
||||
const state = {
|
||||
rules: initial.rules,
|
||||
enabled: initial.enabled,
|
||||
saved: undefined as VoiceCommandRule[] | undefined
|
||||
}
|
||||
return {
|
||||
get saved() {
|
||||
return state.saved
|
||||
},
|
||||
loadRules: () => state.rules,
|
||||
saveRules: (rules) => {
|
||||
state.rules = rules
|
||||
state.saved = rules
|
||||
},
|
||||
loadEnabled: () => state.enabled,
|
||||
saveEnabled: (enabled) => {
|
||||
state.enabled = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
const defaultRules = (): VoiceCommandRule[] => buildDefaultRules(() => `r${seq++}`)
|
||||
|
||||
describe('기본 키워드: 일반 받아쓰기 문장을 명령으로 삼키지 않는다', () => {
|
||||
const dictation = [
|
||||
'요약 보고서 첨부해서 보내드립니다.',
|
||||
'요약.',
|
||||
'설명 드리겠습니다, 이번 배포는 늦어집니다.',
|
||||
'번역 작업은 다음 주에 끝납니다.',
|
||||
'영어로 된 문서를 보내 주세요.',
|
||||
'다듬기 작업이 남았습니다.',
|
||||
'Explain to the team that the deploy is delayed',
|
||||
'Polish the slides before the meeting',
|
||||
'Translate team is out today',
|
||||
'Summarize: nothing yet'
|
||||
]
|
||||
|
||||
for (const text of dictation) {
|
||||
it(`"${text}" 는 매칭되지 않는다`, () => {
|
||||
expect(findRuleMatch(text, defaultRules())).toBeNull()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('기본 키워드: 명령형은 매칭하고 키워드를 떼어낸다', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['번역해줘 이 문장', 'builtin-translate', '이 문장'],
|
||||
['번역해 줘, 오늘 회의 끝', 'builtin-translate', '오늘 회의 끝'],
|
||||
['영어로 번역해줘. 안녕하세요', 'builtin-translate', '안녕하세요'],
|
||||
['Translate this: good morning', 'builtin-translate', 'good morning'],
|
||||
['요약해줘 긴 글', 'builtin-summarize', '긴 글'],
|
||||
['요약해 주세요. 오늘 회의 내용', 'builtin-summarize', '오늘 회의 내용'],
|
||||
['다듬어줘 메일 초안', 'builtin-formal', '메일 초안'],
|
||||
['설명해줘 const x = 1', 'builtin-explain-code', 'const x = 1'],
|
||||
['explain this "for (;;)"', 'builtin-explain-code', '"for (;;)"']
|
||||
]
|
||||
|
||||
for (const [text, instructionId, cleaned] of cases) {
|
||||
it(`"${text}" → ${instructionId}`, () => {
|
||||
const found = findRuleMatch(text, defaultRules())
|
||||
expect(found?.rule.instructionId).toBe(instructionId)
|
||||
expect(found?.cleanedText).toBe(cleaned)
|
||||
})
|
||||
}
|
||||
|
||||
it('명령어만 말하고 내용이 없으면 명령으로 보지 않는다', () => {
|
||||
expect(findRuleMatch('요약해줘.', defaultRules())).toBeNull()
|
||||
expect(findRuleMatch('번역해줘', defaultRules())).toBeNull()
|
||||
})
|
||||
|
||||
it('기본 키워드에 맨 명사가 없다', () => {
|
||||
const all = DEFAULT_VOICE_COMMAND_KEYWORDS.flatMap((e) => e.keywords.map((k) => k.keyword))
|
||||
for (const bare of ['요약', '설명', '번역', '영어로', '다듬기', 'explain', 'polish', 'translate', 'summarize']) {
|
||||
expect(all).not.toContain(bare)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchKeyword: 잘라낸 자리의 구두점과 빈 내용', () => {
|
||||
const table: Array<[string, string, 'prefix' | 'suffix' | 'contains', boolean, string]> = [
|
||||
['짧게, 오늘 회의 내용', '짧게', 'prefix', true, '오늘 회의 내용'],
|
||||
['짧게.', '짧게', 'prefix', false, '짧게.'],
|
||||
['짧게 ...', '짧게', 'prefix', false, '짧게 ...'],
|
||||
['짧게요 오늘', '짧게', 'prefix', false, '짧게요 오늘'],
|
||||
['오늘 회의 내용, 번역해줘', '번역해줘', 'suffix', true, '오늘 회의 내용'],
|
||||
['번역해줘', '번역해줘', 'suffix', false, '번역해줘'],
|
||||
['이 메일 정중하게 써줘', '정중하게', 'contains', true, '이 메일 써줘'],
|
||||
['정중하게', '정중하게', 'contains', false, '정중하게'],
|
||||
['anything', '', 'prefix', false, 'anything']
|
||||
]
|
||||
|
||||
for (const [text, keyword, mode, matched, cleaned] of table) {
|
||||
it(`${mode} "${keyword}" in "${text}"`, () => {
|
||||
expect(matchKeyword(text, keyword, mode)).toEqual({ matched, cleanedText: cleaned })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('레거시 기본값 이관', () => {
|
||||
const legacy = (): VoiceCommandRule[] => [
|
||||
{
|
||||
id: 'a',
|
||||
instructionId: 'builtin-summarize',
|
||||
keywords: [
|
||||
{ keyword: '요약해줘', matchMode: 'prefix' },
|
||||
{ keyword: '요약', matchMode: 'prefix' },
|
||||
{ keyword: 'summarize', matchMode: 'prefix' }
|
||||
],
|
||||
enabled: true,
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
instructionId: 'builtin-translate',
|
||||
keywords: [{ keyword: '영작', matchMode: 'prefix' }],
|
||||
enabled: true,
|
||||
priority: 0
|
||||
}
|
||||
]
|
||||
|
||||
it('손대지 않은 레거시 기본값만 교체하고 사용자 키워드는 둔다', () => {
|
||||
const { rules, migrated } = migrateLegacyDefaultRules(legacy())
|
||||
expect(migrated).toBe(1)
|
||||
expect(rules[0].id).toBe('a')
|
||||
expect(rules[0].keywords.map((k) => k.keyword)).not.toContain('요약')
|
||||
expect(rules[1].keywords).toEqual([{ keyword: '영작', matchMode: 'prefix' }])
|
||||
expect(migrateLegacyDefaultRules(rules).migrated).toBe(0)
|
||||
})
|
||||
|
||||
it('서비스 초기화 때 저장된 레거시 기본값을 이관하고 저장한다', () => {
|
||||
const store = memoryStore({ rules: legacy(), enabled: true })
|
||||
const svc = createVoiceCommandService(store)
|
||||
svc.initialize()
|
||||
svc.initDefaultKeywords()
|
||||
|
||||
expect(svc.match('요약 보고서 첨부해서 보내드립니다.').matched).toBe(false)
|
||||
const cmd = svc.match('요약해줘 긴 글')
|
||||
expect(cmd.instructionId).toBe('builtin-summarize')
|
||||
expect(cmd.cleanedText).toBe('긴 글')
|
||||
expect(store.saved?.find((r) => r.id === 'a')?.keywords.map((k) => k.keyword)).not.toContain(
|
||||
'요약'
|
||||
)
|
||||
})
|
||||
|
||||
it('이관할 것이 없으면 저장하지 않는다', () => {
|
||||
const store = memoryStore({ rules: defaultRules(), enabled: true })
|
||||
createVoiceCommandService(store).initialize()
|
||||
expect(store.saved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// tests/main/services/voice-recording-store-redteam-r3-1.test.ts
|
||||
// 받아쓰기 녹음 저장 순서 회귀 (r3-1, items 11·12):
|
||||
// - 녹음 WAV 는 session-completed(히스토리 생성·history_audio 동기화 예약) 전에 저장된다
|
||||
// - 완료 이벤트는 실제 저장 경로를 싣고, 저장 실패·오디오 없음이면 null 이다
|
||||
// - 히스토리는 이벤트의 경로만 audioLocalPath 로 쓴다
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
import { createFsRecordingStore, type RecordingStore } from '../../../src/main/services/recording/recording-store'
|
||||
import { pcmToWav } from '../../../src/main/services/stt/audio-utils'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
type SttResult = { text: string; segments: never[]; language: string; duration: number; processingTime: number }
|
||||
const result = (text: string): SttResult => ({ text, segments: [], language: 'ko', duration: 1, processingTime: 1 })
|
||||
|
||||
const mockSTT = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
transcribePartial: vi.fn(async () => ''),
|
||||
getModels: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => mockSTT,
|
||||
resetLocalSTTServiceForTests: () => undefined,
|
||||
}))
|
||||
|
||||
const audioBus = new EventEmitter()
|
||||
const mockAudio = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn(), on: vi.fn(), off: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => mockAudio,
|
||||
}))
|
||||
|
||||
const keyBinding = vi.hoisted(() => ({
|
||||
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({
|
||||
getKeyBindingService: () => ({
|
||||
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
|
||||
keyBinding.handler = fn
|
||||
},
|
||||
off: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => config.values[key]),
|
||||
}))
|
||||
|
||||
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => mockInsert,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: () => true }),
|
||||
}))
|
||||
|
||||
const gateway = vi.hoisted(() => ({ processText: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/llm/LlmGateway', () => ({
|
||||
getLlmGateway: () => gateway,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({ getState: () => 'inactive', stop: vi.fn(), start: vi.fn() }),
|
||||
}))
|
||||
|
||||
const chain = vi.hoisted(() => ({ execute: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/ChainService', () => ({
|
||||
getChainService: () => chain,
|
||||
}))
|
||||
|
||||
const instructions = vi.hoisted(() => ({
|
||||
byId: {} as Record<string, { id: string; name: string; prompt: string }>,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CustomInstructionService', () => ({
|
||||
getCustomInstructionService: () => ({ getById: (id: string) => instructions.byId[id] ?? null }),
|
||||
}))
|
||||
|
||||
const voiceCommand = vi.hoisted(() => ({ instructionId: null as string | null }))
|
||||
vi.mock('../../../src/main/services/VoiceCommandService', () => ({
|
||||
getVoiceCommandService: () => ({
|
||||
isEnabled: () => voiceCommand.instructionId !== null,
|
||||
match: (text: string) =>
|
||||
voiceCommand.instructionId
|
||||
? { matched: true, ruleId: 'r', instructionId: voiceCommand.instructionId, cleanedText: text, matchedKeyword: 'k' }
|
||||
: { matched: false, ruleId: null, instructionId: null, cleanedText: text, matchedKeyword: null },
|
||||
}),
|
||||
}))
|
||||
|
||||
const screen = vi.hoisted(() => ({
|
||||
enabled: false,
|
||||
captureContext: vi.fn(),
|
||||
captureSelectedText: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/ScreenContextService', () => ({
|
||||
getScreenContextService: () => ({
|
||||
isEnabled: () => screen.enabled,
|
||||
captureContext: screen.captureContext,
|
||||
captureSelectedText: screen.captureSelectedText,
|
||||
buildContextPrompt: (ctx: { appName: string | null; selectedText: string | null }) =>
|
||||
`[ctx app=${ctx.appName ?? '-'} sel=${ctx.selectedText ?? '-'}]\n`,
|
||||
}),
|
||||
}))
|
||||
|
||||
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
|
||||
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
|
||||
let createWithStore: VoiceModeModule['createVoiceModeServiceForTests']
|
||||
|
||||
const SPEECH = Buffer.alloc(16000 * 2) // 1초
|
||||
|
||||
async function flush(times = 8): Promise<void> {
|
||||
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
|
||||
}
|
||||
|
||||
function advanceClock(ms: number): void {
|
||||
const base = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
|
||||
}
|
||||
|
||||
function key(
|
||||
actionId: 'dictation' | 'hands-free',
|
||||
type: 'pressed' | 'released',
|
||||
isDoublePress: boolean,
|
||||
): KeyBindingTriggerPayload {
|
||||
return {
|
||||
actionId,
|
||||
type,
|
||||
isDoublePress,
|
||||
holdMode: actionId === 'dictation',
|
||||
timestamp: Date.now(),
|
||||
durationMs: 0,
|
||||
} as unknown as KeyBindingTriggerPayload
|
||||
}
|
||||
|
||||
function screenContext(appName: string): { context: { appName: string; windowTitle: string; selectedText: null; capturedAt: number }; selectedTextAttempted: boolean } {
|
||||
return { context: { appName, windowTitle: 'w', selectedText: null, capturedAt: 0 }, selectedTextAttempted: false }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
audioBus.removeAllListeners()
|
||||
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
|
||||
keyBinding.handler = null
|
||||
screen.enabled = false
|
||||
voiceCommand.instructionId = null
|
||||
instructions.byId = {}
|
||||
for (const fn of [
|
||||
mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels,
|
||||
mockAudio.start, mockAudio.stop, mockAudio.on, mockAudio.off, mockInsert.insertText,
|
||||
gateway.processText, chain.execute, screen.captureContext, screen.captureSelectedText,
|
||||
]) {
|
||||
fn.mockReset()
|
||||
}
|
||||
mockSTT.initialize.mockResolvedValue(undefined)
|
||||
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
|
||||
mockSTT.transcribePartial.mockResolvedValue('')
|
||||
mockSTT.getModels.mockReturnValue([
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||
])
|
||||
mockAudio.start.mockResolvedValue(undefined)
|
||||
mockAudio.stop.mockResolvedValue(undefined)
|
||||
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.on(ev, fn)
|
||||
})
|
||||
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.off(ev, fn)
|
||||
})
|
||||
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
|
||||
gateway.processText.mockImplementation(async (text: string) => `LLM(${text})`)
|
||||
chain.execute.mockResolvedValue({ finalText: '체인 결과' })
|
||||
screen.captureContext.mockResolvedValue(screenContext('Code'))
|
||||
screen.captureSelectedText.mockResolvedValue('선택 문장')
|
||||
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||
mod.resetVoiceModeServiceForTests()
|
||||
getVoiceModeService = mod.getVoiceModeService
|
||||
createWithStore = mod.createVoiceModeServiceForTests
|
||||
})
|
||||
|
||||
|
||||
async function recordAndStop(store: RecordingStore): Promise<{ order: string[]; completed: Array<{ audioPath: string | null }> }> {
|
||||
const order: string[] = []
|
||||
const completed: Array<{ audioPath: string | null }> = []
|
||||
const svc = createWithStore({
|
||||
save: async (sessionId, pcm) => {
|
||||
const saved = await store.save(sessionId, pcm)
|
||||
order.push('saved')
|
||||
return saved
|
||||
},
|
||||
})
|
||||
svc.on('session-completed', ({ audioPath }) => {
|
||||
order.push('completed')
|
||||
completed.push({ audioPath })
|
||||
})
|
||||
await svc.startSession('dictation')
|
||||
await vi.waitFor(() => expect(svc.isActive).toBe(true))
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(3000)
|
||||
await svc.stopSession()
|
||||
await flush()
|
||||
return { order, completed }
|
||||
}
|
||||
|
||||
describe('녹음 저장 → 완료 순서', () => {
|
||||
it('WAV 저장이 끝난 뒤에 session-completed 가 나가고 실제 경로를 싣는다', async () => {
|
||||
let release: () => void = () => undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const store: RecordingStore = {
|
||||
save: async (sessionId) => {
|
||||
await gate
|
||||
return `/rec/${sessionId}.wav`
|
||||
},
|
||||
}
|
||||
const pending = recordAndStop(store)
|
||||
await flush()
|
||||
release()
|
||||
const { order, completed } = await pending
|
||||
expect(order).toEqual(['saved', 'completed'])
|
||||
expect(completed[0].audioPath).toMatch(/^\/rec\/.+\.wav$/)
|
||||
})
|
||||
|
||||
it('저장에 실패하면 audioPath 는 null 이다 (존재하지 않는 경로를 남기지 않음)', async () => {
|
||||
const { completed } = await recordAndStop({ save: async () => null })
|
||||
expect(completed).toEqual([{ audioPath: null }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fs 녹음 저장소', () => {
|
||||
it('pcmToWav 와 같은 바이트로 recordings/<id>.wav 에 쓴다', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-rec-r3-1-'))
|
||||
try {
|
||||
const store = createFsRecordingStore({
|
||||
resolveDir: async () => dir,
|
||||
mkdir: (target) => fs.promises.mkdir(target, { recursive: true }),
|
||||
writeFile: (target, data) => fs.promises.writeFile(target, data),
|
||||
})
|
||||
const pcm = Buffer.alloc(3200, 1)
|
||||
const saved = await store.save('abc', pcm)
|
||||
expect(saved).toBe(path.join(dir, 'abc.wav'))
|
||||
expect(fs.readFileSync(saved as string).equals(pcmToWav(pcm, 16000, 1, 16))).toBe(true)
|
||||
expect(await store.save('empty', Buffer.alloc(0))).toBeNull()
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('쓰기 실패는 예외 대신 null', async () => {
|
||||
const store = createFsRecordingStore({
|
||||
resolveDir: async () => '/nowhere',
|
||||
mkdir: async () => undefined,
|
||||
writeFile: async () => {
|
||||
throw new Error('EACCES')
|
||||
},
|
||||
})
|
||||
expect(await store.save('x', Buffer.alloc(10))).toBeNull()
|
||||
})
|
||||
})
|
||||
81
apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts
Normal file
81
apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// tests/main/sync/audio-sync-redteam-r3-1.test.ts
|
||||
// 동기화 회귀 (r3-1):
|
||||
// - 파일 전사 기록(audioLocalPath = 사용자가 고른 원본 미디어)은 '녹음 동기화'로 올리지 않는다
|
||||
// - 대기 중인 원격 삭제를 뒤늦은 upsert(예: 자동 제목)가 덮어쓰지 않는다
|
||||
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import { history } from '../../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import { listLocalAudioOwners, localAudioPath } from '../../../src/main/services/sync/audio-sync'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: FakeSyncRemote
|
||||
let engine: SyncEngine
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
|
||||
remote = new FakeSyncRemote(USER)
|
||||
engine = new SyncEngine({ remote, userId: USER })
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-audio-r3-1-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
engine.dispose()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
testDb.close()
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function insertHistory(mode: 'dictation' | 'file-transcription', fileName: string): string {
|
||||
const id = crypto.randomUUID()
|
||||
const file = path.join(tmpDir, fileName)
|
||||
fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake media'))
|
||||
const now = Date.now()
|
||||
testDb.db
|
||||
.insert(history)
|
||||
.values({ id, originalText: 'spoken', duration: 2, mode, audioLocalPath: file, createdAt: now, updatedAt: now })
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
describe('파일 전사 원본은 녹음으로 올리지 않는다 (item 19)', () => {
|
||||
it('파일 전사 기록의 원본 미디어는 업로드 대상이 아니다', async () => {
|
||||
const fileId = insertHistory('file-transcription', 'meeting-video.mp4')
|
||||
const dictationId = insertHistory('dictation', 'dictation.wav')
|
||||
|
||||
expect(localAudioPath('history', fileId)).toBeNull()
|
||||
expect(localAudioPath('history', dictationId)).not.toBeNull()
|
||||
expect(listLocalAudioOwners('history')).toEqual([dictationId])
|
||||
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(remote.objects.has(`audio/${USER}/desktop/history/${fileId}.mp4`)).toBe(false)
|
||||
expect(remote.objects.has(`audio/${USER}/desktop/history/${dictationId}.wav`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('대기 중인 삭제는 뒤늦은 upsert 에 덮이지 않는다 (item 37)', () => {
|
||||
it('delete 뒤에 같은 행의 upsert 가 와도 op 는 delete 로 남는다', () => {
|
||||
const id = crypto.randomUUID()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
enqueueChange('history', id, 'delete')
|
||||
enqueueChange('history', id, 'upsert')
|
||||
expect(pendingOps('history').get(id)).toBe('delete')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
// tests/main/sync/cloud-credentials-redteam-r3-1.test.ts
|
||||
// 로그아웃이 오프라인·5xx 로 서버 폐기에 실패해도(auth-js 가 { error } 를 돌려주고 _removeSession 을 건너뜀)
|
||||
// 자격 증명 포트(Cloud STT·Premium·realtime-token)가 옛 계정 토큰을 더는 내주지 않는지 검증한다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudcred-r3-1-${process.pid}`)
|
||||
const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token')
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
type AuthCallback = (event: string, session: unknown) => void
|
||||
const state = {
|
||||
currentUserId: '_local' as string | null,
|
||||
authCallback: null as AuthCallback | null,
|
||||
tierGate: null as Promise<void> | null,
|
||||
}
|
||||
return {
|
||||
state,
|
||||
db: {
|
||||
openForUser: (userId: string) => {
|
||||
state.currentUserId = userId
|
||||
return { created: false, dbPath: `/db/${userId}` }
|
||||
},
|
||||
openLocal: () => {
|
||||
state.currentUserId = '_local'
|
||||
return { created: false, dbPath: '/db/_local' }
|
||||
},
|
||||
},
|
||||
enqueueChange: (..._args: unknown[]) => undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
refreshSession: null as null | ((...args: unknown[]) => Promise<unknown>),
|
||||
signOut: null as null | ((...args: unknown[]) => Promise<unknown>),
|
||||
clients: [] as Array<{ session: null | { access_token: string; user: { id: string } } }>,
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => path.join(os.tmpdir(), `d3ro-cloudcred-r3-1-${process.pid}`),
|
||||
getVersion: () => '1.0.0',
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plain: string) => Buffer.from(plain, 'utf-8'),
|
||||
decryptString: (data: Buffer) => data.toString('utf-8'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: () => undefined,
|
||||
configSet: vi.fn(),
|
||||
onConfigChanged: () => () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({
|
||||
LOCAL_USER_ID: '_local',
|
||||
openForUser: vi.fn((userId: string) => h.db.openForUser(userId)),
|
||||
openLocal: vi.fn(() => h.db.openLocal()),
|
||||
closeCurrent: vi.fn(() => {
|
||||
h.state.currentUserId = null
|
||||
}),
|
||||
getCurrentUserId: () => h.state.currentUserId,
|
||||
importLocalModeData: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/sync-outbox', () => ({
|
||||
enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)),
|
||||
getSyncState: () => null,
|
||||
setSyncState: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/SyncEngine', () => ({
|
||||
SyncEngine: class {
|
||||
on(): void {}
|
||||
dispose(): void {}
|
||||
async whenIdle(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
async runFullSync() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
async flush() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
async pull() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
getStatus() {
|
||||
return { pending: 0, parked: 0 }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/sync-adapters', () => ({
|
||||
SyncAbortedError: class SyncAbortedError extends Error {},
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} }))
|
||||
vi.mock('../../../src/main/services/sync/device-registration', () => ({
|
||||
checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })),
|
||||
currentDeviceInfo: () => ({}),
|
||||
unregisterDesktopDevice: vi.fn(async () => undefined),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] }))
|
||||
vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} }))
|
||||
vi.mock('../../../src/main/services/sync/settings-sync', () => ({
|
||||
SETTINGS_ROW_ID: 'settings',
|
||||
SYNCED_CONFIG_KEYS: [],
|
||||
isApplyingRemoteSettings: () => false,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] }))
|
||||
vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) }))
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/VoiceModeService', () => ({
|
||||
getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/MeetingModeService', () => ({
|
||||
getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({ stop: vi.fn(async () => null) }),
|
||||
}))
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => {
|
||||
const query = (): Record<string, unknown> => {
|
||||
const builder: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder
|
||||
builder.maybeSingle = async () => {
|
||||
if (h.state.tierGate) await h.state.tierGate
|
||||
return { data: null, error: null }
|
||||
}
|
||||
return builder
|
||||
}
|
||||
const channel = {
|
||||
on() {
|
||||
return channel
|
||||
},
|
||||
subscribe() {
|
||||
return channel
|
||||
},
|
||||
unsubscribe: async () => undefined,
|
||||
state: 'joined',
|
||||
}
|
||||
return {
|
||||
createClient: () => {
|
||||
const clientState = { session: null as null | { access_token: string; user: { id: string } } }
|
||||
spies.clients.push(clientState)
|
||||
return {
|
||||
auth: {
|
||||
refreshSession: async (...args: unknown[]) => {
|
||||
const result = (await spies.refreshSession!(...args)) as { data: { session: unknown } }
|
||||
if (result.data.session) clientState.session = result.data.session as typeof clientState.session
|
||||
return result
|
||||
},
|
||||
signOut: (...args: unknown[]) => spies.signOut!(...args),
|
||||
onAuthStateChange: (cb: (event: string, session: unknown) => void) => {
|
||||
h.state.authCallback = cb
|
||||
return { data: { subscription: { unsubscribe: () => undefined } } }
|
||||
},
|
||||
getSession: async () => ({ data: { session: clientState.session } }),
|
||||
stopAutoRefresh: vi.fn(async () => undefined),
|
||||
},
|
||||
from: () => query(),
|
||||
channel: () => channel,
|
||||
realtime: { setAuth: async () => undefined },
|
||||
functions: {
|
||||
invoke: vi.fn(async () => ({ data: { ok: true }, error: null })),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService'
|
||||
import { cloudSyncCredentials, createCloudSttGateway } from '../../../src/main/services/cloud/cloud-credentials'
|
||||
import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver'
|
||||
|
||||
function session() {
|
||||
return { access_token: 'at-user-1', refresh_token: 'rt-new', user: { id: 'user-1', email: 'u@example.test' } }
|
||||
}
|
||||
|
||||
describe('로그아웃 실패(retryable) 뒤 자격 증명 폐기', () => {
|
||||
beforeEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
spies.clients.length = 0
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
fs.mkdirSync(USER_DATA, { recursive: true })
|
||||
fs.writeFileSync(TOKEN_FILE, JSON.stringify({ v: 1, rt: 'rt-1', uid: 'user-1' }))
|
||||
h.state.currentUserId = 'user-1'
|
||||
h.state.tierGate = null
|
||||
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
|
||||
// auth-js: 네트워크 실패면 throw 하지 않고 { error } 를 돌려주며 클라이언트 세션을 지우지 않는다
|
||||
spies.signOut = vi.fn(async () => ({
|
||||
error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' },
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('로그인 중에는 토큰과 Edge Function 호출이 가능하다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
expect(cloudSyncCredentials.hasCredentials()).toBe(true)
|
||||
await expect(cloudSyncCredentials.accessToken()).resolves.toBe('at-user-1')
|
||||
const result = await cloudSyncCredentials.invoke('realtime-token', {})
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it('signOut 이 { error } 를 돌려줘도 접근자는 모두 null / no session 이다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
// 옛 클라이언트는 여전히 세션을 들고 있다(auth-js 동작 재현)
|
||||
expect(spies.clients[0].session).not.toBeNull()
|
||||
|
||||
await sync.signOut()
|
||||
|
||||
expect(sync.isAuthenticated()).toBe(false)
|
||||
expect(cloudSyncCredentials.hasCredentials()).toBe(false)
|
||||
await expect(sync.getAccessToken()).resolves.toBeNull()
|
||||
await expect(cloudSyncCredentials.accessToken()).resolves.toBeNull()
|
||||
const invoked = await cloudSyncCredentials.invoke('realtime-token', {})
|
||||
expect(invoked.error?.message).toMatch(/session/i)
|
||||
const streamed = await cloudSyncCredentials.invokeStream('llm-proxy', {})
|
||||
expect(streamed.stream).toBeNull()
|
||||
expect(streamed.error).not.toBeNull()
|
||||
})
|
||||
|
||||
it('실패한 signOut 뒤 옛 클라이언트는 자동 갱신을 멈추고 새 클라이언트로 바뀐다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
await sync.signOut()
|
||||
expect(spies.clients).toHaveLength(2)
|
||||
expect(spies.clients[1].session).toBeNull()
|
||||
})
|
||||
|
||||
it('Cloud STT 드라이버는 로그아웃 뒤 업로드하지 않는다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
await sync.signOut()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
const driver = new D3ROCloudDriver({
|
||||
...createCloudSttGateway(),
|
||||
getSupabaseUrl: () => 'https://example.supabase.co',
|
||||
getAnonKey: () => 'anon',
|
||||
})
|
||||
await expect(driver.transcribe(Buffer.alloc(3200))).rejects.toThrow()
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
// 레드팀 r3-2: 다른 기기에서 재색인한 지식 문서가 이미 문서를 가진 데스크톱에도 반영된다.
|
||||
// - 원문이 바뀌었으면 청크를 교체하고(옛 임베딩 제거) 다시 색인한다
|
||||
// - 원문이 같으면(자기 push의 에코 등) 로컬 청크·임베딩을 건드리지 않는다
|
||||
// - 재업로드가 중간이면 잘린 청크로 덮지 않고 미뤘다가 완성본을 반영한다
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import { ragChunks, ragDocuments } from '../../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import {
|
||||
getCustomInstructionService,
|
||||
resetCustomInstructionServiceForTests,
|
||||
} from '../../../src/main/services/CustomInstructionService'
|
||||
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
|
||||
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
|
||||
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { planRemoteDocument, sameChunks, usableChunks } from '../../../src/main/services/rag/remote-document'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: FakeSyncRemote
|
||||
let engine: SyncEngine
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
resetCustomInstructionServiceForTests()
|
||||
resetDictationTemplateServiceForTests()
|
||||
resetMeetingDocTemplateServiceForTests()
|
||||
resetRAGServiceForTests()
|
||||
getCustomInstructionService().initialize()
|
||||
// 임베딩 서버(Ollama)는 없다 — 원문만 저장되는 경로를 탄다.
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
|
||||
remote = new FakeSyncRemote(USER)
|
||||
engine = new SyncEngine({ remote, userId: USER })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
engine.dispose()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
resetRAGServiceForTests()
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
function localChunks(id: string): Array<{ content: string; embedding: string }> {
|
||||
return testDb.db
|
||||
.select()
|
||||
.from(ragChunks)
|
||||
.where(eq(ragChunks.documentId, id))
|
||||
.all()
|
||||
.sort((a, b) => a.chunkIndex - b.chunkIndex)
|
||||
.map((c) => ({ content: c.content, embedding: c.embedding }))
|
||||
}
|
||||
|
||||
function markEmbedded(id: string): void {
|
||||
testDb.db.update(ragChunks).set({ embedding: '[1,2]' }).where(eq(ragChunks.documentId, id)).run()
|
||||
testDb.db.update(ragDocuments).set({ indexed: true, indexedAt: 1 }).where(eq(ragDocuments.id, id)).run()
|
||||
}
|
||||
|
||||
/** 다른 데스크톱(A)의 재색인 push를 흉내: 행 upsert(updated_at 이동) → 청크 전부 교체 */
|
||||
function remoteReindex(id: string, chunks: string[], uploaded = chunks.length): void {
|
||||
remote.mobileUpdate('knowledge_documents', id, { chunk_count: chunks.length })
|
||||
const rows = remote.rows('knowledge_chunks')
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].document_id === id) rows.splice(i, 1)
|
||||
chunks.slice(0, uploaded).forEach((content, chunk_index) =>
|
||||
rows.push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
|
||||
)
|
||||
}
|
||||
|
||||
function uploadRest(id: string, chunks: string[], from: number): void {
|
||||
chunks.slice(from).forEach((content, offset) =>
|
||||
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: from + offset, content })
|
||||
)
|
||||
}
|
||||
|
||||
async function seedSyncedDocument(chunks: string[]): Promise<string> {
|
||||
const id = crypto.randomUUID()
|
||||
remote.mobileInsert('knowledge_documents', { id, title: 'Doc', file_name: 'doc.md', file_type: 'md', chunk_count: chunks.length })
|
||||
chunks.forEach((content, chunk_index) =>
|
||||
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
|
||||
)
|
||||
await engine.runFullSync()
|
||||
expect(localChunks(id).map((c) => c.content)).toEqual(chunks)
|
||||
// 받은 직후 시작된 색인 시도(임베딩 서버 없음)가 끝난 뒤에 "이미 색인됨" 상태를 만든다
|
||||
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0))
|
||||
markEmbedded(id)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('재색인된 지식 문서 동기화', () => {
|
||||
it('다른 기기에서 원문이 바뀌면 이미 있는 문서의 청크를 교체하고 옛 임베딩을 버린다', async () => {
|
||||
const id = await seedSyncedDocument(['old intro', 'old body'])
|
||||
|
||||
remoteReindex(id, ['new intro', 'new body', 'new appendix'])
|
||||
await engine.pull()
|
||||
|
||||
const chunks = localChunks(id)
|
||||
expect(chunks.map((c) => c.content)).toEqual(['new intro', 'new body', 'new appendix'])
|
||||
// 옛 벡터가 새 원문에 붙어 검색되지 않는다(임베딩 서버가 없어 아직 비어 있다)
|
||||
expect(chunks.every((c) => c.embedding === '')).toBe(true)
|
||||
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
expect(doc?.chunkCount).toBe(3)
|
||||
expect(doc?.indexed).toBe(false)
|
||||
})
|
||||
|
||||
it('원문이 같으면(에코·메타데이터만 변경) 로컬 청크와 임베딩을 그대로 둔다', async () => {
|
||||
const id = await seedSyncedDocument(['same a', 'same b'])
|
||||
|
||||
remote.mobileUpdate('knowledge_documents', id, { indexed: true })
|
||||
await engine.pull()
|
||||
|
||||
expect(localChunks(id)).toEqual([
|
||||
{ content: 'same a', embedding: '[1,2]' },
|
||||
{ content: 'same b', embedding: '[1,2]' },
|
||||
])
|
||||
expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()?.indexed).toBe(true)
|
||||
})
|
||||
|
||||
it('재업로드가 중간이면 잘린 청크로 덮지 않고, 완성되면 반영한다', async () => {
|
||||
const id = await seedSyncedDocument(['v1 a', 'v1 b'])
|
||||
const next = ['v2 a', 'v2 b', 'v2 c']
|
||||
|
||||
remoteReindex(id, next, 1)
|
||||
await engine.pull()
|
||||
expect(localChunks(id).map((c) => c.content)).toEqual(['v1 a', 'v1 b'])
|
||||
|
||||
uploadRest(id, next, 1)
|
||||
await engine.pull()
|
||||
expect(localChunks(id).map((c) => c.content)).toEqual(next)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planRemoteDocument', () => {
|
||||
it('로컬에 없으면 insert, 같으면 skip, 다르면 replace', () => {
|
||||
expect(planRemoteDocument(null, ['a', ' ', 'b'])).toEqual({ kind: 'insert', chunks: ['a', 'b'] })
|
||||
expect(planRemoteDocument(['a', 'b'], ['a', 'b'])).toEqual({ kind: 'skip' })
|
||||
expect(planRemoteDocument(['a', 'b'], ['a', 'c'])).toEqual({ kind: 'replace', chunks: ['a', 'c'] })
|
||||
expect(planRemoteDocument(['a'], ['a', 'b'])).toEqual({ kind: 'replace', chunks: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('받은 청크가 전부 비어 있으면 로컬을 비우지 않는다', () => {
|
||||
expect(planRemoteDocument(['a'], ['', ' '])).toEqual({ kind: 'skip' })
|
||||
expect(planRemoteDocument(null, [])).toEqual({ kind: 'skip' })
|
||||
})
|
||||
|
||||
it('빈 청크는 비교에서 빠진다', () => {
|
||||
expect(usableChunks(['a', '', ' b '])).toEqual(['a', ' b '])
|
||||
expect(planRemoteDocument(['a', ''], ['a'])).toEqual({ kind: 'skip' })
|
||||
expect(sameChunks(['a'], ['a', 'b'])).toBe(false)
|
||||
})
|
||||
})
|
||||
269
apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts
Normal file
269
apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// 레드팀 r3-0: push 전 tombstone 읽기 실패 시 upsert 보류(fail closed),
|
||||
// 서버에 없는 활성 명령이 설정 항목을 끝없는 재시도에 묶어 설정 pull을 막던 문제.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import { history } from '../../../src/main/db/schema'
|
||||
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import {
|
||||
getCustomInstructionService,
|
||||
resetCustomInstructionServiceForTests,
|
||||
} from '../../../src/main/services/CustomInstructionService'
|
||||
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
|
||||
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
|
||||
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
||||
import {
|
||||
SyncRemoteError,
|
||||
type RemotePageRequest,
|
||||
type RemoteRow,
|
||||
} from '../../../src/main/services/sync/sync-types'
|
||||
import { pushableEntries } from '../../../src/main/services/sync/push-gate-policy'
|
||||
import {
|
||||
isActiveInstructionMissing,
|
||||
planActiveInstructionStep,
|
||||
shouldResyncSettingsAfterInstructionPush,
|
||||
} from '../../../src/main/services/sync/active-instruction-push-policy'
|
||||
import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
const U = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
/**
|
||||
* 운영 서버처럼 INSERT 때 updated_at을 서버 시각으로 새로 찍고(0037 stamp_sync_timestamp_v1),
|
||||
* sync_tombstones 읽기를 지정한 횟수만큼 일시 오류로 실패시키는 원격.
|
||||
*/
|
||||
class ServerStampingRemote extends FakeSyncRemote {
|
||||
failTombstoneReads = 0
|
||||
|
||||
override async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
|
||||
if (request.table === 'sync_tombstones' && this.failTombstoneReads > 0) {
|
||||
this.failTombstoneReads--
|
||||
throw new SyncRemoteError('canceling statement due to statement timeout', '57014', true)
|
||||
}
|
||||
return super.fetchPage(request)
|
||||
}
|
||||
|
||||
override async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise<void> {
|
||||
const existing = new Set(this.rows(table).map((r) => String(r.id)))
|
||||
await super.upsert(table, rows, onConflict)
|
||||
for (const row of this.rows(table)) {
|
||||
if (!existing.has(String(row.id))) row.updated_at = this.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const offlineEmbedder: EmbeddingPort = {
|
||||
model: 'test-embed',
|
||||
ensureModel: () => Promise.reject(new Error('no embedding server in tests')),
|
||||
embed: () => Promise.reject(new Error('no embedding server in tests')),
|
||||
}
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: ServerStampingRemote
|
||||
let engine: SyncEngine
|
||||
let clock: number
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
resetCustomInstructionServiceForTests()
|
||||
resetDictationTemplateServiceForTests()
|
||||
resetMeetingDocTemplateServiceForTests()
|
||||
resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 })
|
||||
getCustomInstructionService().initialize()
|
||||
remote = new ServerStampingRemote(USER)
|
||||
clock = Date.parse('2026-09-28T00:00:00.000Z')
|
||||
engine = new SyncEngine({ remote, userId: USER, now: () => clock })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
engine.dispose()
|
||||
resetRAGServiceForTests()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
function historyRow(id: string, text: string): Record<string, unknown> {
|
||||
return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' }
|
||||
}
|
||||
|
||||
function localHistoryIds(): string[] {
|
||||
return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort()
|
||||
}
|
||||
|
||||
describe('push 전 tombstone 읽기 실패 → upsert 보류(fail closed)', () => {
|
||||
it('폰에서 지운 행의 대기 upsert가 서버에 행을 되살리지 않고, 다음 pull에서 삭제가 반영된다', async () => {
|
||||
const x = crypto.randomUUID()
|
||||
remote.mobileInsert('history', historyRow(x, 'will be deleted on phone'))
|
||||
await engine.runFullSync()
|
||||
expect(localHistoryIds()).toEqual([x])
|
||||
|
||||
// 데스크톱의 대기 편집(즐겨찾기 등) + 폰의 삭제(T1)
|
||||
enqueueChange('history', x, 'upsert', clock)
|
||||
remote.mobileDelete('history', x)
|
||||
|
||||
remote.failTombstoneReads = 1
|
||||
const flushed = await engine.flush()
|
||||
expect(flushed.errors.some((e) => e.startsWith('tombstones:'))).toBe(true)
|
||||
expect(flushed.pushed).toBe(0)
|
||||
expect(remote.find('history', x)).toBeUndefined()
|
||||
expect(pendingOps('history').get(x)).toBe('upsert')
|
||||
|
||||
await engine.pull()
|
||||
expect(localHistoryIds()).toEqual([])
|
||||
expect(pendingOps('history').has(x)).toBe(false)
|
||||
expect(remote.find('history', x)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tombstone 읽기가 실패해도 삭제는 보낸다', async () => {
|
||||
const x = crypto.randomUUID()
|
||||
const y = crypto.randomUUID()
|
||||
remote.mobileInsert('history', historyRow(x, 'edit me'))
|
||||
remote.mobileInsert('history', historyRow(y, 'delete me'))
|
||||
await engine.runFullSync()
|
||||
|
||||
enqueueChange('history', x, 'upsert', clock)
|
||||
testDb.db.delete(history).where(eq(history.id, y)).run()
|
||||
enqueueChange('history', y, 'delete', clock)
|
||||
remote.failTombstoneReads = 1
|
||||
const flushed = await engine.flush()
|
||||
|
||||
expect(remote.find('history', y)).toBeUndefined()
|
||||
expect(pendingOps('history').has(y)).toBe(false)
|
||||
expect(pendingOps('history').get(x)).toBe('upsert')
|
||||
expect(flushed.pushed).toBe(1)
|
||||
})
|
||||
|
||||
it('runFullSync에서 tombstone 읽기가 실패하면 최초 대조·upsert를 미루고 다음 flush에서 이어 간다', async () => {
|
||||
const local = crypto.randomUUID()
|
||||
const at = clock
|
||||
testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run()
|
||||
|
||||
remote.failTombstoneReads = 1
|
||||
await engine.runFullSync()
|
||||
expect(remote.find('history', local)).toBeUndefined()
|
||||
|
||||
await engine.flush()
|
||||
expect(remote.find('history', local)?.original_text).toBe('offline note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('서버에 없는 활성 명령과 설정 동기화', () => {
|
||||
function rejectInstructionUploads(error: SyncRemoteError | null): void {
|
||||
remote.rejectRow = (table) => (table === 'custom_instructions' ? error : null)
|
||||
}
|
||||
|
||||
function seedRemoteSettings(): void {
|
||||
remote.mobileInsert('user_settings', {
|
||||
locale: 'ko',
|
||||
theme_mode: 'system',
|
||||
auto_polish_enabled: true,
|
||||
revision: 1,
|
||||
active_instruction_id: null,
|
||||
})
|
||||
}
|
||||
|
||||
it('올라가지 못한(보관된) 명령이 활성이어도 설정 항목은 완료되고, 폰의 설정 변경을 계속 받는다', async () => {
|
||||
seedRemoteSettings()
|
||||
const command = getCustomInstructionService().create({ name: 'Local only', description: '', prompt: 'Keep it local' })
|
||||
rejectInstructionUploads(new SyncRemoteError('value too long for type character varying(4000)', '22001', false))
|
||||
await engine.runFullSync()
|
||||
expect(pendingOps('custom_instructions').get(command.id)).toBe('upsert')
|
||||
|
||||
// 서버에 없는 명령을 활성으로 고르고 테마를 바꾼다(CloudSyncService가 설정 항목을 넣는다)
|
||||
configSet('activeInstructionId', command.id)
|
||||
configSet('theme', 'dark')
|
||||
enqueueChange('user_settings', 'self', 'upsert', clock)
|
||||
const flushed = await engine.flush()
|
||||
expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([])
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
expect(remote.rows('user_settings')[0].theme_mode).toBe('dark')
|
||||
expect(configGet('activeInstructionId')).toBe(command.id)
|
||||
expect(remote.calls).not.toContain('rpc:set_active_custom_instruction')
|
||||
|
||||
const settings = remote.rows('user_settings')[0]
|
||||
Object.assign(settings, { theme_mode: 'light', revision: Number(settings.revision) + 1, updated_at: remote.now() })
|
||||
await engine.pull()
|
||||
expect(configGet('theme')).toBe('light')
|
||||
})
|
||||
|
||||
it('outbox에 없는데 서버가 모르는 명령(P0002)은 설정 항목을 재시도에 묶지 않는다', async () => {
|
||||
seedRemoteSettings()
|
||||
const command = getCustomInstructionService().create({ name: 'Gone', description: '', prompt: 'Vanished on server' })
|
||||
await engine.runFullSync()
|
||||
// 서버에서 tombstone 없이 사라진 경우(보존 기간 정리 등)
|
||||
const rows = remote.rows('custom_instructions')
|
||||
rows.splice(rows.findIndex((r) => r.id === command.id), 1)
|
||||
|
||||
configSet('activeInstructionId', command.id)
|
||||
enqueueChange('user_settings', 'self', 'upsert', clock)
|
||||
const flushed = await engine.flush()
|
||||
expect(remote.calls).toContain('rpc:set_active_custom_instruction')
|
||||
expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([])
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
})
|
||||
|
||||
it('미룬 활성 명령이 나중에 서버에 올라가면 설정을 다시 올려 서버 활성 명령을 맞춘다', async () => {
|
||||
seedRemoteSettings()
|
||||
const command = getCustomInstructionService().create({ name: 'Late', description: '', prompt: 'Arrives later' })
|
||||
rejectInstructionUploads(new SyncRemoteError('canceling statement due to statement timeout', '57014', true))
|
||||
await engine.runFullSync()
|
||||
configSet('activeInstructionId', command.id)
|
||||
enqueueChange('user_settings', 'self', 'upsert', clock)
|
||||
await engine.flush()
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull()
|
||||
expect(remote.calls).not.toContain('rpc:set_active_custom_instruction')
|
||||
|
||||
rejectInstructionUploads(null)
|
||||
clock += 60 * 60_000
|
||||
await engine.flush()
|
||||
expect(remote.find('custom_instructions', command.id)).toBeDefined()
|
||||
expect(pendingOps('user_settings').has('self')).toBe(true)
|
||||
|
||||
await engine.flush()
|
||||
expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id)
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('push-gate-policy', () => {
|
||||
const entries = [
|
||||
{ op: 'upsert' as const, rowId: 'a' },
|
||||
{ op: 'delete' as const, rowId: 'b' },
|
||||
]
|
||||
|
||||
it('tombstone을 반영했으면 전부, 실패했으면 삭제만 push한다', () => {
|
||||
expect(pushableEntries(entries, { tombstonesSynced: true })).toEqual(entries)
|
||||
expect(pushableEntries(entries, { tombstonesSynced: false })).toEqual([{ op: 'delete', rowId: 'b' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('active-instruction-push-policy', () => {
|
||||
it('서버에 아직 없는 명령은 미루고, 나머지는 RPC로 보낸다', () => {
|
||||
expect(planActiveInstructionStep(null, new Set([U]))).toEqual({ kind: 'none' })
|
||||
expect(planActiveInstructionStep({ instructionId: U }, new Set([U]))).toEqual({ kind: 'defer', instructionId: U })
|
||||
expect(planActiveInstructionStep({ instructionId: U }, new Set())).toEqual({ kind: 'rpc', instructionId: U })
|
||||
expect(planActiveInstructionStep({ instructionId: null }, new Set([U]))).toEqual({ kind: 'rpc', instructionId: null })
|
||||
})
|
||||
|
||||
it('P0002만 "서버에 명령 없음"으로 본다', () => {
|
||||
expect(isActiveInstructionMissing(new SyncRemoteError('instruction_not_found', 'P0002', true))).toBe(true)
|
||||
expect(isActiveInstructionMissing(new SyncRemoteError('fetch failed', 'network', true))).toBe(false)
|
||||
})
|
||||
|
||||
it('미룬 명령이 올라갔을 때만 설정을 다시 올린다', () => {
|
||||
expect(shouldResyncSettingsAfterInstructionPush(U, [U])).toBe(true)
|
||||
expect(shouldResyncSettingsAfterInstructionPush(U, ['other'])).toBe(false)
|
||||
expect(shouldResyncSettingsAfterInstructionPush('', [U])).toBe(false)
|
||||
expect(shouldResyncSettingsAfterInstructionPush(null, [U])).toBe(false)
|
||||
})
|
||||
})
|
||||
83
apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts
Normal file
83
apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// tests/main/update-policy-redteam-r3-5.test.ts
|
||||
// 원격 정책의 안전 필드(killSwitch, stagingPercentage)가 형식이 틀리면 가장 허용적인 값이
|
||||
// 아니라 가장 보수적인 값으로 읽히는지 확인한다.
|
||||
//
|
||||
// 회귀: `"killSwitch": "true"` 가 false 로, `"stagingPercentage": "5"` / 5.5 가 100% 로
|
||||
// 조용히 바뀌어 운영자 의도와 반대로 전체 배포되던 문제.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
evaluateUpdateOffer,
|
||||
parseUpdatePolicy,
|
||||
} from '../../src/main/update-policy'
|
||||
|
||||
const BASE = {
|
||||
schemaVersion: 1,
|
||||
defaultChannel: 'latest',
|
||||
channels: {
|
||||
latest: { allowPrerelease: false },
|
||||
beta: { allowPrerelease: true },
|
||||
alpha: { allowPrerelease: true },
|
||||
},
|
||||
minimumSupportedVersion: '1.0.0',
|
||||
forceInstallBelow: null,
|
||||
fullInstallOnMajorChange: true,
|
||||
fullInstallVersionGap: 3,
|
||||
stagingPercentage: 100,
|
||||
killSwitch: false,
|
||||
}
|
||||
|
||||
describe('parseUpdatePolicy — 안전 필드 fail-closed', () => {
|
||||
it.each(['true', 'false', 1, 0, null, {}])('killSwitch=%j 는 켜진 것으로 본다', (value) => {
|
||||
expect(parseUpdatePolicy({ ...BASE, killSwitch: value }).killSwitch).toBe(true)
|
||||
})
|
||||
|
||||
it.each([5.5, '5', null, Number.NaN, '100'])('stagingPercentage=%j 는 0% 로 본다', (value) => {
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: value }).stagingPercentage).toBe(0)
|
||||
})
|
||||
|
||||
it('형식이 틀린 킬 스위치는 업데이트 제안을 막는다', () => {
|
||||
const policy = parseUpdatePolicy({ ...BASE, killSwitch: 'true' })
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy,
|
||||
channel: 'latest',
|
||||
currentVersion: '1.8.0',
|
||||
targetVersion: '1.9.0',
|
||||
skippedVersion: null,
|
||||
deviceId: 'device-a',
|
||||
})
|
||||
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
|
||||
})
|
||||
|
||||
it('형식이 틀린 staging 은 필수 업데이트가 아니면 아무에게도 노출하지 않는다', () => {
|
||||
const policy = parseUpdatePolicy({ ...BASE, stagingPercentage: '5' })
|
||||
for (const deviceId of ['a', 'b', 'c', 'd', 'e', 'f']) {
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy,
|
||||
channel: 'latest',
|
||||
currentVersion: '1.8.0',
|
||||
targetVersion: '1.9.0',
|
||||
skippedVersion: null,
|
||||
deviceId,
|
||||
})
|
||||
expect(offer).toMatchObject({ action: 'ignore', reason: 'rollout' })
|
||||
}
|
||||
})
|
||||
|
||||
it('필드가 아예 없으면 기존처럼 기본값을 쓴다', () => {
|
||||
const { killSwitch: _k, stagingPercentage: _s, ...rest } = BASE
|
||||
const parsed = parseUpdatePolicy(rest)
|
||||
expect(parsed.killSwitch).toBe(DEFAULT_UPDATE_POLICY.killSwitch)
|
||||
expect(parsed.stagingPercentage).toBe(DEFAULT_UPDATE_POLICY.stagingPercentage)
|
||||
})
|
||||
|
||||
it('올바른 값은 그대로(범위 밖 정수는 기존처럼 잘라서) 읽는다', () => {
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 5, killSwitch: false })).toMatchObject({
|
||||
stagingPercentage: 5,
|
||||
killSwitch: false,
|
||||
})
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 250 }).stagingPercentage).toBe(100)
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: -3 }).stagingPercentage).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -83,6 +83,7 @@ import { getMeetingModeService } from '../../src/main/services/MeetingModeServic
|
|||
import { configSet } from '../../src/main/services/ConfigService'
|
||||
import { getDatabase } from '../../src/main/db'
|
||||
import { meetingSessions } from '../../src/main/db/schema'
|
||||
import { syncStateRecordingLedger } from '../../src/main/services/meeting/local-recording-ledger'
|
||||
|
||||
function row(id: string) {
|
||||
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
|
||||
|
|
@ -163,7 +164,10 @@ describe('앱 종료·중단 복구', () => {
|
|||
|
||||
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
|
||||
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
|
||||
// 전사가 없는 행은 이 기기에서 시작한 녹음(원장 표식)일 때만 닫는다 — redteam r3: 폰의 진행 중 회의 보호
|
||||
insertSession('stuck-empty', { status: 'processing' })
|
||||
syncStateRecordingLedger.mark('stuck-empty')
|
||||
insertSession('phone-live', { status: 'recording' })
|
||||
insertSession('done', { status: 'completed', rawTranscript: 'x' })
|
||||
|
||||
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
|
||||
|
|
@ -171,6 +175,8 @@ describe('앱 종료·중단 복구', () => {
|
|||
expect(row('stuck-with-text')?.status).toBe('completed')
|
||||
expect(row('stuck-with-text')?.endedAt).not.toBeNull()
|
||||
expect(row('stuck-empty')?.status).toBe('error')
|
||||
// 표식도 전사도 없는 행은 다른 기기(폰)의 진행 중 회의일 수 있어 그대로 둔다
|
||||
expect(row('phone-live')?.status).toBe('recording')
|
||||
expect(row('done')?.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
// 받아쓰기 템플릿 필드 id 회귀 테스트 (redteam r3-7)
|
||||
// 버그: 편집기가 필드 개수로 id를 만들고(중간 삭제 후 재사용) 이름을 그대로 id로 써서
|
||||
// 빈 id·중복 id 템플릿이 저장됐다. 세션에서 같은 id 필드의 받아쓴 값이 앞 값을 덮어써
|
||||
// 출력({{id}})에서 앞 값이 조용히 사라졌다. 이제 서비스가 저장 전에 TemplateInvalidFormat 으로 거부한다.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { TemplateField } from '@d3ro/core/types'
|
||||
import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService'
|
||||
import { useRedHarness } from './harness'
|
||||
|
||||
useRedHarness()
|
||||
|
||||
function field(id: string): TemplateField {
|
||||
return { id, name: id, label: id, promptText: '', required: true, maxDurationSec: 30 }
|
||||
}
|
||||
|
||||
function expectInvalidFormat(run: () => unknown): void {
|
||||
let caught: unknown
|
||||
try {
|
||||
run()
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(D3ROError)
|
||||
expect((caught as D3ROError).code).toBe(ErrorCode.TemplateInvalidFormat)
|
||||
}
|
||||
|
||||
describe('받아쓰기 템플릿 필드 id 검증', () => {
|
||||
it('중복 필드 id 템플릿 생성을 거부하고 저장하지 않는다', () => {
|
||||
const svc = getDictationTemplateService()
|
||||
const before = svc.getAll().length
|
||||
// 편집기 시나리오: [field1, field2, field3] → field1 삭제 → 추가 → [field2, field3, field3]
|
||||
expectInvalidFormat(() =>
|
||||
svc.create({
|
||||
name: 'dup',
|
||||
description: '',
|
||||
fields: [field('field2'), field('field3'), field('field3')],
|
||||
outputFormat: '{{field2}} {{field3}}',
|
||||
}),
|
||||
)
|
||||
expect(svc.getAll()).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('빈/공백 필드 id 템플릿 생성을 거부한다', () => {
|
||||
const svc = getDictationTemplateService()
|
||||
expectInvalidFormat(() =>
|
||||
svc.create({ name: 'empty', description: '', fields: [field('a'), field('')], outputFormat: '{{a}}' }),
|
||||
)
|
||||
expectInvalidFormat(() =>
|
||||
svc.create({ name: 'blank', description: '', fields: [field(' ')], outputFormat: '' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('수정으로 중복 필드 id를 넣으면 거부하고 기존 필드를 유지한다', () => {
|
||||
const svc = getDictationTemplateService()
|
||||
const created = svc.create({
|
||||
name: 'ok',
|
||||
description: '',
|
||||
fields: [field('a'), field('b')],
|
||||
outputFormat: '{{a}} {{b}}',
|
||||
})
|
||||
expectInvalidFormat(() => svc.update({ id: created.id, fields: [field('a'), field('a')] }))
|
||||
expect(svc.getById(created.id)?.fields.map((f) => f.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('필드를 바꾸지 않는 수정과 유효한 필드 수정은 그대로 된다', () => {
|
||||
const svc = getDictationTemplateService()
|
||||
const created = svc.create({ name: 'ok', description: '', fields: [field('a')], outputFormat: '{{a}}' })
|
||||
expect(svc.update({ id: created.id, name: 'renamed' }).name).toBe('renamed')
|
||||
expect(svc.update({ id: created.id, fields: [field('a'), field('b')] }).fields).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('유효한 템플릿은 필드마다 받아쓴 값이 모두 출력에 남는다', () => {
|
||||
const svc = getDictationTemplateService()
|
||||
const created = svc.create({
|
||||
name: 'flow',
|
||||
description: '',
|
||||
fields: [field('field2'), field('field3'), field('field4')],
|
||||
outputFormat: '{{field2}}|{{field3}}|{{field4}}',
|
||||
})
|
||||
const outputs: string[] = []
|
||||
svc.on('session-completed', (e: { outputText: string }) => outputs.push(e.outputText))
|
||||
svc.startSession(created.id)
|
||||
svc.consumeDictatedText('one')
|
||||
svc.consumeDictatedText('two')
|
||||
svc.consumeDictatedText('three')
|
||||
expect(outputs).toEqual(['one|two|three'])
|
||||
})
|
||||
})
|
||||
145
apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts
Normal file
145
apps/desktop/tests/unit/error-recovery-redteam-r3-21.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
normalizeErrorCode,
|
||||
offersForceRestart,
|
||||
presentError,
|
||||
settingsTabOf,
|
||||
} from '../../src/renderer/navigation/error-recovery'
|
||||
import { buildMeetingErrorInfo, forceRestartLabel } from '../../src/renderer/pages/meeting/meeting-error'
|
||||
import { canDismissOnboarding } from '../../src/renderer/components/onboarding-dismiss'
|
||||
|
||||
// 회귀:
|
||||
// 1) voice:error 101(STT 모델 없음)이 닫을 수 없는 온보딩 모달을 열어 메인 창을 가뒀다.
|
||||
// 2) 회의 후처리 실패(882)가 '회의를 시작할 수 없습니다'로 보이고, 주 버튼이 새 녹음을 강제로 시작했다.
|
||||
// 3) 회의 오류 분류가 ErrorCode 와 맞지 않았다(1400=충돌, 200-220=마이크).
|
||||
|
||||
describe('presentError — voice', () => {
|
||||
it('101 은 온보딩이 아니라 설정 → STT 탭 딥링크로 복구한다', () => {
|
||||
const p = presentError({ context: 'voice', code: ErrorCode.STTModelNotFound })
|
||||
expect(p.category).toBe('sttModelMissing')
|
||||
expect(p.messageKey).toBe('voice.error.modelMissing')
|
||||
expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }])
|
||||
expect(settingsTabOf(p)).toBe('stt')
|
||||
})
|
||||
|
||||
it.each([103, 130, 131, 132])('%i 은 엔진 오류 문구를 유지하고 복구 동작은 없다', (code) => {
|
||||
const p = presentError({ context: 'voice', code })
|
||||
expect(p.category).toBe('sttEngine')
|
||||
expect(p.messageKey).toBe('voice.error.engine')
|
||||
expect(p.recoveries).toEqual([])
|
||||
})
|
||||
|
||||
it('300 경고는 llmSkipped 문구, 300 오류는 원문 그대로', () => {
|
||||
expect(presentError({ context: 'voice', code: 300, severity: 'warning' }).messageKey).toBe('voice.warning.llmSkipped')
|
||||
const asError = presentError({ context: 'voice', code: 300, severity: 'error' })
|
||||
expect(asError.category).toBe('general')
|
||||
expect(asError.messageKey).toBeNull()
|
||||
})
|
||||
|
||||
it('모르는 코드는 원문을 쓰고 설정 딥링크가 없다', () => {
|
||||
const p = presentError({ context: 'voice', code: 999 })
|
||||
expect(p.messageKey).toBeNull()
|
||||
expect(settingsTabOf(p)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentError — meetingProcessing', () => {
|
||||
it('882 는 후처리 실패이고 강제 재시작(새 녹음)을 절대 제시하지 않는다', () => {
|
||||
const p = presentError({ context: 'meetingProcessing', code: ErrorCode.MeetingProcessingFailed, message: '후처리 실패: SQLITE_FULL' })
|
||||
expect(p.category).toBe('processing')
|
||||
expect(offersForceRestart(p)).toBe(false)
|
||||
})
|
||||
|
||||
it('후처리 문구에 model/already 같은 키워드가 있어도 processing 이다', () => {
|
||||
for (const message of ['후처리 실패: model busy', '후처리 실패: already locked', '후처리 실패: audio']) {
|
||||
const p = presentError({ context: 'meetingProcessing', code: 882, message })
|
||||
expect(p.category).toBe('processing')
|
||||
expect(offersForceRestart(p)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentError — meetingStart', () => {
|
||||
it.each([ErrorCode.MeetingAlreadyRecording, ErrorCode.CaptionAlreadyActive])('%i 는 충돌 → 강제 재시작', (code) => {
|
||||
const p = presentError({ context: 'meetingStart', code, message: 'x' })
|
||||
expect(p.category).toBe('collision')
|
||||
expect(p.recoveries).toEqual([{ kind: 'forceRestart' }])
|
||||
})
|
||||
|
||||
it('1400 은 ErrorCode 가 아니므로 충돌로 보지 않는다', () => {
|
||||
expect(presentError({ context: 'meetingStart', code: 1400, message: 'x' }).category).toBe('general')
|
||||
})
|
||||
|
||||
it.each([101, 103, 110, 130, 132, 140])('STT 코드 %i 는 STT 설정 + 재시도', (code) => {
|
||||
const p = presentError({ context: 'meetingStart', code, message: 'x' })
|
||||
expect(p.category).toBe('stt')
|
||||
expect(p.recoveries).toEqual([{ kind: 'openSettings', tab: 'stt' }, { kind: 'forceRestart' }])
|
||||
})
|
||||
|
||||
it.each([400, 401, 410, 420, 431])('오디오 코드 %i 는 오디오 설정 + 재시도', (code) => {
|
||||
const p = presentError({ context: 'meetingStart', code, message: 'x' })
|
||||
expect(p.category).toBe('audio')
|
||||
expect(settingsTabOf(p)).toBe('audio')
|
||||
})
|
||||
|
||||
it.each([200, 210, 220])('TTS 코드 %i 는 마이크 오류가 아니다', (code) => {
|
||||
const p = presentError({ context: 'meetingStart', code, message: 'x' })
|
||||
expect(p.category).toBe('general')
|
||||
expect(settingsTabOf(p)).toBeNull()
|
||||
})
|
||||
|
||||
it('시작 경로에 882 가 와도 강제 재시작을 주지 않는다', () => {
|
||||
expect(offersForceRestart(presentError({ context: 'meetingStart', code: 882 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('코드가 없는 catch 경로는 문구 키워드로 폴백한다', () => {
|
||||
expect(presentError({ context: 'meetingStart', message: 'Caption already active' }).category).toBe('collision')
|
||||
expect(presentError({ context: 'meetingStart', message: 'whisper sidecar died' }).category).toBe('stt')
|
||||
expect(presentError({ context: 'meetingStart', message: 'mic not found' }).category).toBe('audio')
|
||||
expect(presentError({ context: 'meetingStart', message: 'ipc timeout' }).category).toBe('general')
|
||||
})
|
||||
|
||||
it('숫자 문자열 코드도 정규화한다', () => {
|
||||
expect(normalizeErrorCode('880')).toBe(880)
|
||||
expect(normalizeErrorCode('STT_UNAVAILABLE')).toBeNull()
|
||||
expect(normalizeErrorCode(undefined)).toBeNull()
|
||||
expect(presentError({ context: 'meetingStart', code: '880' }).category).toBe('collision')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMeetingErrorInfo', () => {
|
||||
it('후처리 실패는 시작 실패 제목이 아니고 복구 버튼이 없다', () => {
|
||||
const info = buildMeetingErrorInfo('meetingProcessing', '후처리 실패: disk full', 882)
|
||||
expect(info.title).not.toBe('회의를 시작할 수 없습니다')
|
||||
expect(info.category).toBe('processing')
|
||||
expect(info.recoveries).toEqual([])
|
||||
expect(info.code).toBe(882)
|
||||
expect(info.message).toBe('후처리 실패: disk full')
|
||||
})
|
||||
|
||||
it('충돌 라벨과 일반 라벨이 다르다', () => {
|
||||
expect(forceRestartLabel('collision')).not.toBe(forceRestartLabel('general'))
|
||||
})
|
||||
|
||||
it('코드·문구가 없으면 카테고리 기본값을 쓴다', () => {
|
||||
const info = buildMeetingErrorInfo('meetingStart')
|
||||
expect(info.category).toBe('general')
|
||||
expect(info.code).toBe('MEETING_START_ERROR')
|
||||
expect(info.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canDismissOnboarding', () => {
|
||||
it('첫 실행(미완료)은 success 전까지 닫을 수 없다', () => {
|
||||
expect(canDismissOnboarding('select_mode', false)).toBe(false)
|
||||
expect(canDismissOnboarding('local_ollama_setup', false)).toBe(false)
|
||||
expect(canDismissOnboarding('success', false)).toBe(true)
|
||||
})
|
||||
|
||||
it('이미 온보딩을 마친 사용자는 어느 단계에서든 닫을 수 있다', () => {
|
||||
for (const phase of ['select_mode', 'local_ollama_setup', 'online_auth', 'failed'] as const) {
|
||||
expect(canDismissOnboarding(phase, true)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MouseButton, VK } from '@d3ro/core/keybinding'
|
||||
|
||||
type EffectFn = () => void | (() => void)
|
||||
const effects: EffectFn[] = []
|
||||
|
||||
vi.mock('react', () => ({
|
||||
useEffect: (fn: EffectFn) => {
|
||||
effects.push(fn)
|
||||
},
|
||||
}))
|
||||
|
||||
const {
|
||||
KeyBindingCaptureSession,
|
||||
resolveCapturePort,
|
||||
useKeyBindingCaptureSuspension,
|
||||
} = await import('../../src/renderer/components/keybinding/keybinding-capture')
|
||||
const { BROWSER_BUTTON_TO_MOUSE, NO_MODIFIERS, buildBinding, resolveKeyCode } = await import(
|
||||
'../../src/renderer/components/keybinding/keybinding-recording'
|
||||
)
|
||||
|
||||
const PICKER = path.resolve(
|
||||
__dirname,
|
||||
'../../src/renderer/components/keybinding/KeyBindingPicker.tsx'
|
||||
)
|
||||
|
||||
function fakePort() {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
port: {
|
||||
beginCapture: vi.fn(async () => {
|
||||
calls.push('begin')
|
||||
}),
|
||||
endCapture: vi.fn(async () => {
|
||||
calls.push('end')
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('KeyBindingPicker — 녹화 중 전역 트리거 중단 (r3-23)', () => {
|
||||
beforeEach(() => {
|
||||
effects.length = 0
|
||||
})
|
||||
|
||||
it('피커가 열려 있는 동안 캡처 억제 훅을 open 으로 건다', () => {
|
||||
const source = fs.readFileSync(PICKER, 'utf8')
|
||||
expect(source).toContain('useKeyBindingCaptureSuspension(open)')
|
||||
// 전역 on/off 는 hotkeyEnabled 를 저장하므로 캡처에 쓰면 안 된다.
|
||||
expect(source).not.toMatch(/keybinding\s*\.\s*setEnabled/)
|
||||
})
|
||||
|
||||
it('열리면 beginCapture, 닫히거나 언마운트되면 endCapture 를 한 번씩 보낸다', () => {
|
||||
const { port, calls } = fakePort()
|
||||
useKeyBindingCaptureSuspension(true, () => port)
|
||||
expect(effects).toHaveLength(1)
|
||||
const cleanup = effects[0]()
|
||||
expect(calls).toEqual(['begin'])
|
||||
expect(typeof cleanup).toBe('function')
|
||||
if (typeof cleanup === 'function') cleanup()
|
||||
expect(calls).toEqual(['begin', 'end'])
|
||||
})
|
||||
|
||||
it('닫혀 있으면 아무것도 보내지 않는다', () => {
|
||||
const { port, calls } = fakePort()
|
||||
useKeyBindingCaptureSuspension(false, () => port)
|
||||
const cleanup = effects[0]()
|
||||
expect(cleanup).toBeUndefined()
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('세션은 begin/end 를 중복 전송하지 않고 begin 없이 end 를 보내지 않는다', () => {
|
||||
const { port, calls } = fakePort()
|
||||
const session = new KeyBindingCaptureSession(port)
|
||||
session.end()
|
||||
session.begin()
|
||||
session.begin()
|
||||
expect(session.isActive).toBe(true)
|
||||
session.end()
|
||||
session.end()
|
||||
expect(session.isActive).toBe(false)
|
||||
expect(calls).toEqual(['begin', 'end'])
|
||||
})
|
||||
|
||||
it('IPC 실패는 녹화를 막지 않는다', async () => {
|
||||
const session = new KeyBindingCaptureSession({
|
||||
beginCapture: () => Promise.reject(new Error('no handler')),
|
||||
endCapture: () => Promise.reject(new Error('no handler')),
|
||||
})
|
||||
expect(() => {
|
||||
session.begin()
|
||||
session.end()
|
||||
}).not.toThrow()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('포트가 없으면(null) 조용히 아무것도 하지 않는다', () => {
|
||||
const session = new KeyBindingCaptureSession(null)
|
||||
session.begin()
|
||||
expect(session.isActive).toBe(false)
|
||||
})
|
||||
|
||||
it('preload API 구조로 포트를 판별한다', () => {
|
||||
expect(resolveCapturePort(undefined)).toBeNull()
|
||||
expect(resolveCapturePort({ setEnabled: () => undefined })).toBeNull()
|
||||
expect(resolveCapturePort({ beginCapture: () => undefined })).toBeNull()
|
||||
|
||||
const begin = vi.fn(() => Promise.resolve({ success: true }))
|
||||
const end = vi.fn(() => Promise.resolve({ success: true }))
|
||||
const port = resolveCapturePort({ beginCapture: begin, endCapture: end })
|
||||
expect(port).not.toBeNull()
|
||||
void port?.beginCapture()
|
||||
void port?.endCapture()
|
||||
expect(begin).toHaveBeenCalledTimes(1)
|
||||
expect(end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keybinding-recording — 추출된 녹화 규칙 (동작 보존)', () => {
|
||||
it('좌/우 수정자를 location 으로 구분한다', () => {
|
||||
expect(resolveKeyCode({ keyCode: 17, which: 17, location: 1 })).toBe(VK.CtrlLeft)
|
||||
expect(resolveKeyCode({ keyCode: 17, which: 17, location: 2 })).toBe(VK.CtrlRight)
|
||||
expect(resolveKeyCode({ keyCode: 16, which: 16, location: 2 })).toBe(VK.ShiftRight)
|
||||
expect(resolveKeyCode({ keyCode: 18, which: 18, location: 1 })).toBe(VK.AltLeft)
|
||||
expect(resolveKeyCode({ keyCode: 93, which: 93, location: 2 })).toBe(VK.MetaRight)
|
||||
expect(resolveKeyCode({ keyCode: 93, which: 93, location: 0 })).toBe(93)
|
||||
expect(resolveKeyCode({ keyCode: 0, which: 65, location: 0 })).toBe(65)
|
||||
})
|
||||
|
||||
it('주 키는 비수정자 키, 없으면 마지막 수정자', () => {
|
||||
expect(buildBinding([], NO_MODIFIERS)).toBeNull()
|
||||
const combo = buildBinding([VK.CtrlLeft, 65], { ...NO_MODIFIERS, ctrl: true })
|
||||
expect(combo).toMatchObject({ device: 'keyboard', code: 65, ctrl: true })
|
||||
const mods = buildBinding([VK.CtrlLeft, VK.AltRight], { ...NO_MODIFIERS, ctrl: true })
|
||||
expect(mods?.code).toBe(VK.AltRight)
|
||||
})
|
||||
|
||||
it('브라우저 마우스 버튼을 uiohook 코드로 옮긴다', () => {
|
||||
expect(BROWSER_BUTTON_TO_MOUSE[1]).toBe(MouseButton.Middle)
|
||||
expect(BROWSER_BUTTON_TO_MOUSE[2]).toBe(MouseButton.Right)
|
||||
expect(BROWSER_BUTTON_TO_MOUSE[3]).toBe(MouseButton.Back)
|
||||
expect(BROWSER_BUTTON_TO_MOUSE[4]).toBe(MouseButton.Forward)
|
||||
})
|
||||
})
|
||||
129
apps/desktop/tests/unit/stt-model-download-redteam-r3-24.test.ts
Normal file
129
apps/desktop/tests/unit/stt-model-download-redteam-r3-24.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// tests/unit/stt-model-download-redteam-r3-24.test.ts
|
||||
// STT 탭 모델 다운로드가 실패/취소로 끝나도 '다운로드 중 (x%)' 에 멈추지 않는지 회귀 검증.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ErrorCode, ipcError, ipcSuccess, type IPCResult } from '@d3ro/core/errors'
|
||||
import {
|
||||
classifyModelDownloadResult,
|
||||
releaseIfCurrent,
|
||||
runModelDownload,
|
||||
type ModelDownloadView,
|
||||
} from '../../src/renderer/components/stt/modelDownload'
|
||||
|
||||
/** STTTab 의 downloadingModelId / downloadError 상태를 흉내 내는 가짜 view */
|
||||
function fakeView(): ModelDownloadView & {
|
||||
downloadingModelId: string | null
|
||||
error: { modelId: string; message: string } | null
|
||||
events: string[]
|
||||
} {
|
||||
const state = {
|
||||
downloadingModelId: null as string | null,
|
||||
error: null as { modelId: string; message: string } | null,
|
||||
events: [] as string[],
|
||||
}
|
||||
return Object.assign(state, {
|
||||
started: (id: string) => {
|
||||
state.events.push(`started:${id}`)
|
||||
state.error = null
|
||||
state.downloadingModelId = id
|
||||
},
|
||||
succeeded: (id: string) => {
|
||||
state.events.push(`succeeded:${id}`)
|
||||
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
|
||||
},
|
||||
cancelled: (id: string) => {
|
||||
state.events.push(`cancelled:${id}`)
|
||||
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
|
||||
},
|
||||
failed: (id: string, message: string) => {
|
||||
state.events.push(`failed:${id}`)
|
||||
state.downloadingModelId = releaseIfCurrent(state.downloadingModelId, id)
|
||||
state.error = { modelId: id, message }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('runModelDownload', () => {
|
||||
it('ipcError(사이드카 기동 실패 등)면 진행 표시를 풀고 오류 메시지를 노출한다', async () => {
|
||||
const view = fakeView()
|
||||
const outcome = await runModelDownload(
|
||||
'large-v3-turbo',
|
||||
{ download: async () => ipcError<void>(ErrorCode.STTModelDownloadFailed, '다운로드 시작 실패 (HTTP 500): boom') },
|
||||
view,
|
||||
)
|
||||
expect(outcome).toEqual({ kind: 'failed', message: '다운로드 시작 실패 (HTTP 500): boom' })
|
||||
expect(view.downloadingModelId).toBeNull()
|
||||
expect(view.error).toEqual({ modelId: 'large-v3-turbo', message: '다운로드 시작 실패 (HTTP 500): boom' })
|
||||
})
|
||||
|
||||
it('취소 결과는 오류 없이 진행 표시만 푼다', async () => {
|
||||
const view = fakeView()
|
||||
const outcome = await runModelDownload(
|
||||
'small',
|
||||
{ download: async () => ipcError<void>(ErrorCode.STTModelDownloadCancelled, '모델 다운로드 취소: small') },
|
||||
view,
|
||||
)
|
||||
expect(outcome.kind).toBe('cancelled')
|
||||
expect(view.downloadingModelId).toBeNull()
|
||||
expect(view.error).toBeNull()
|
||||
})
|
||||
|
||||
it('invoke 자체가 reject 되어도 진행 표시를 푼다', async () => {
|
||||
const view = fakeView()
|
||||
const outcome = await runModelDownload(
|
||||
'small',
|
||||
{ download: async () => { throw new Error('ipc channel closed') } },
|
||||
view,
|
||||
)
|
||||
expect(outcome).toEqual({ kind: 'failed', message: 'ipc channel closed' })
|
||||
expect(view.downloadingModelId).toBeNull()
|
||||
})
|
||||
|
||||
it('성공하면 succeeded 로 끝나고 오류가 없다', async () => {
|
||||
const view = fakeView()
|
||||
const outcome = await runModelDownload('small', { download: async () => ipcSuccess(undefined) }, view)
|
||||
expect(outcome.kind).toBe('succeeded')
|
||||
expect(view.events).toEqual(['started:small', 'succeeded:small'])
|
||||
expect(view.downloadingModelId).toBeNull()
|
||||
expect(view.error).toBeNull()
|
||||
})
|
||||
|
||||
it('늦게 도착한 A 실패가 그 사이 시작한 B 의 진행 표시를 지우지 않는다', async () => {
|
||||
const view = fakeView()
|
||||
let failA: (r: IPCResult<void>) => void = () => undefined
|
||||
const pendingA = runModelDownload(
|
||||
'A',
|
||||
{ download: () => new Promise<IPCResult<void>>((resolve) => { failA = resolve }) },
|
||||
view,
|
||||
)
|
||||
view.started('B')
|
||||
failA(ipcError<void>(ErrorCode.STTModelDownloadFailed, 'A failed'))
|
||||
await pendingA
|
||||
expect(view.downloadingModelId).toBe('B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyModelDownloadResult', () => {
|
||||
it('실패 코드별 결말', () => {
|
||||
expect(classifyModelDownloadResult(ipcSuccess(undefined))).toEqual({ kind: 'succeeded' })
|
||||
expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadCancelled, 'x'))).toEqual({ kind: 'cancelled' })
|
||||
expect(classifyModelDownloadResult(ipcError(ErrorCode.STTModelDownloadFailed, 'y'))).toEqual({
|
||||
kind: 'failed',
|
||||
message: 'y',
|
||||
})
|
||||
expect(classifyModelDownloadResult({ thrown: 'raw' })).toEqual({ kind: 'failed', message: 'raw' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('STTTab wiring', () => {
|
||||
it('handleDownloadModel 이 IPC 결과를 버리지 않고 runModelDownload 로 처리한다', () => {
|
||||
const src = readFileSync(
|
||||
join(__dirname, '../../src/renderer/components/STTTab.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
expect(src).toContain('await runModelDownload(')
|
||||
expect(src).not.toMatch(/await window\.electronAPI\.stt\.downloadModel\(\{ modelId \}\)\s*\n\s*\}, \[\]\)/)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
// tests/unit/system-audio-capture-redteam-r3-22.test.ts
|
||||
// SystemAudioCaptureController: stop-during-start 취소, off/on 중복 획득 방지,
|
||||
// getDisplayMedia 실패 시 loopback 해제를 fake port로 검증한다.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
SystemAudioCaptureController,
|
||||
type PcmPipeline,
|
||||
type SystemAudioPort,
|
||||
type SystemAudioStream,
|
||||
} from '../../src/renderer/utils/systemAudioCaptureController'
|
||||
import { float32ToPcm16 } from '../../src/renderer/utils/systemAudioCapture'
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (v: T) => void
|
||||
reject: (e: unknown) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
class FakeStream implements SystemAudioStream {
|
||||
stopped = false
|
||||
videoDropped = false
|
||||
constructor(readonly id: number, private readonly audio = true) {}
|
||||
dropVideoTracks(): void {
|
||||
this.videoDropped = true
|
||||
}
|
||||
hasAudioTrack(): boolean {
|
||||
return this.audio
|
||||
}
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
class FakePipeline implements PcmPipeline {
|
||||
closed = false
|
||||
constructor(readonly stream: FakeStream, readonly onPcm: (b: ArrayBuffer) => void) {}
|
||||
close(): void {
|
||||
this.closed = true
|
||||
}
|
||||
}
|
||||
|
||||
class FakePort implements SystemAudioPort<FakeStream> {
|
||||
loopbackEnabled = false
|
||||
enableCalls = 0
|
||||
disableCalls = 0
|
||||
displayRequests: Deferred<FakeStream>[] = []
|
||||
pipelines: FakePipeline[] = []
|
||||
/** 루프백 핸들러가 꺼진 상태에서 getDisplayMedia 요청 → 실제 Electron처럼 실패 */
|
||||
requestsWithoutLoopback = 0
|
||||
|
||||
async enableLoopback(): Promise<void> {
|
||||
this.enableCalls++
|
||||
this.loopbackEnabled = true
|
||||
}
|
||||
async disableLoopback(): Promise<void> {
|
||||
this.disableCalls++
|
||||
this.loopbackEnabled = false
|
||||
}
|
||||
getDisplayMedia(): Promise<FakeStream> {
|
||||
if (!this.loopbackEnabled) this.requestsWithoutLoopback++
|
||||
const d = deferred<FakeStream>()
|
||||
this.displayRequests.push(d)
|
||||
return d.promise
|
||||
}
|
||||
createPcmPipeline(stream: FakeStream, onPcm: (b: ArrayBuffer) => void): PcmPipeline {
|
||||
const p = new FakePipeline(stream, onPcm)
|
||||
this.pipelines.push(p)
|
||||
return p
|
||||
}
|
||||
openPipelines(): FakePipeline[] {
|
||||
return this.pipelines.filter((p) => !p.closed)
|
||||
}
|
||||
}
|
||||
|
||||
/** 마이크로태스크 큐 비우기 */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve()
|
||||
}
|
||||
|
||||
function setup(): { port: FakePort; ctrl: SystemAudioCaptureController<FakeStream>; onPcm: ReturnType<typeof vi.fn> } {
|
||||
const port = new FakePort()
|
||||
const onPcm = vi.fn()
|
||||
const ctrl = new SystemAudioCaptureController(port, { onPcm })
|
||||
return { port, ctrl, onPcm }
|
||||
}
|
||||
|
||||
describe('SystemAudioCaptureController', () => {
|
||||
it('starts: enable loopback → getDisplayMedia → disable loopback → pipeline', async () => {
|
||||
const { port, ctrl, onPcm } = setup()
|
||||
const started = ctrl.start()
|
||||
expect(ctrl.getState()).toBe('starting')
|
||||
await flush()
|
||||
const stream = new FakeStream(1)
|
||||
port.displayRequests[0].resolve(stream)
|
||||
await started
|
||||
|
||||
expect(ctrl.getState()).toBe('active')
|
||||
expect(ctrl.isActive()).toBe(true)
|
||||
expect(port.enableCalls).toBe(1)
|
||||
expect(port.disableCalls).toBe(1)
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
expect(stream.videoDropped).toBe(true)
|
||||
expect(port.openPipelines()).toHaveLength(1)
|
||||
expect(port.pipelines[0].onPcm).toBe(onPcm)
|
||||
|
||||
ctrl.stop()
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
expect(port.pipelines[0].closed).toBe(true)
|
||||
expect(stream.stopped).toBe(true)
|
||||
})
|
||||
|
||||
it('stop while getDisplayMedia is pending cancels the start and releases the stream', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const started = ctrl.start()
|
||||
await flush()
|
||||
expect(port.displayRequests).toHaveLength(1)
|
||||
|
||||
ctrl.stop() // 대기 중 STOP 도착
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
|
||||
const stream = new FakeStream(1)
|
||||
port.displayRequests[0].resolve(stream)
|
||||
await expect(started).resolves.toBeUndefined()
|
||||
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
expect(stream.stopped).toBe(true)
|
||||
expect(port.pipelines).toHaveLength(0) // 캡처 파이프라인이 만들어지지 않음
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('stop issued synchronously after start acquires nothing', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const started = ctrl.start()
|
||||
ctrl.stop()
|
||||
await started
|
||||
expect(port.displayRequests).toHaveLength(0)
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
})
|
||||
|
||||
it('stop while enableLoopback is pending disables loopback without requesting media', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const enable = deferred<void>()
|
||||
port.enableLoopback = async () => {
|
||||
port.enableCalls++
|
||||
await enable.promise
|
||||
port.loopbackEnabled = true
|
||||
}
|
||||
const started = ctrl.start()
|
||||
await flush()
|
||||
expect(port.enableCalls).toBe(1)
|
||||
ctrl.stop()
|
||||
enable.resolve()
|
||||
await started
|
||||
expect(port.displayRequests).toHaveLength(0)
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
expect(port.disableCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('start while starting returns the same pending promise (no second acquisition)', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const a = ctrl.start()
|
||||
const b = ctrl.start()
|
||||
expect(b).toBe(a)
|
||||
await flush()
|
||||
expect(port.enableCalls).toBe(1)
|
||||
expect(port.displayRequests).toHaveLength(1)
|
||||
port.displayRequests[0].resolve(new FakeStream(1))
|
||||
await a
|
||||
expect(port.openPipelines()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('start while active is a no-op', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const a = ctrl.start()
|
||||
await flush()
|
||||
port.displayRequests[0].resolve(new FakeStream(1))
|
||||
await a
|
||||
await ctrl.start()
|
||||
expect(port.enableCalls).toBe(1)
|
||||
expect(port.openPipelines()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('on → off → on inside the pending window leaves exactly one stoppable capture', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const first = ctrl.start()
|
||||
await flush()
|
||||
ctrl.stop()
|
||||
const second = ctrl.start()
|
||||
await flush()
|
||||
|
||||
// 두 번째 start는 첫 starter 정리 전까지 loopback/getDisplayMedia를 건드리지 않는다
|
||||
expect(port.displayRequests).toHaveLength(1)
|
||||
|
||||
const s1 = new FakeStream(1)
|
||||
port.displayRequests[0].resolve(s1)
|
||||
await first
|
||||
await flush()
|
||||
expect(s1.stopped).toBe(true)
|
||||
|
||||
// 이제 두 번째 starter가 자기 loopback 핸들러로 요청
|
||||
expect(port.displayRequests).toHaveLength(2)
|
||||
expect(port.requestsWithoutLoopback).toBe(0)
|
||||
const s2 = new FakeStream(2)
|
||||
port.displayRequests[1].resolve(s2)
|
||||
await second
|
||||
|
||||
expect(ctrl.getState()).toBe('active')
|
||||
expect(port.openPipelines()).toHaveLength(1)
|
||||
expect(port.openPipelines()[0].stream).toBe(s2)
|
||||
|
||||
ctrl.stop()
|
||||
expect(port.openPipelines()).toHaveLength(0)
|
||||
expect(s2.stopped).toBe(true)
|
||||
})
|
||||
|
||||
it('on → off → on → off leaves nothing running', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const first = ctrl.start()
|
||||
await flush()
|
||||
ctrl.stop()
|
||||
const second = ctrl.start()
|
||||
ctrl.stop()
|
||||
|
||||
port.displayRequests[0].resolve(new FakeStream(1))
|
||||
await first
|
||||
await second
|
||||
await flush()
|
||||
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
expect(port.displayRequests).toHaveLength(1) // 두 번째 starter는 시작 전에 취소됨
|
||||
expect(port.pipelines).toHaveLength(0)
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('failed getDisplayMedia always disables loopback and rejects to the caller', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const started = ctrl.start()
|
||||
await flush()
|
||||
port.displayRequests[0].reject(new Error('denied'))
|
||||
await expect(started).rejects.toThrow('denied')
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
expect(port.disableCalls).toBe(1)
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
|
||||
// 실패 후 재시작 가능
|
||||
const again = ctrl.start()
|
||||
await flush()
|
||||
port.displayRequests[1].resolve(new FakeStream(2))
|
||||
await again
|
||||
expect(ctrl.getState()).toBe('active')
|
||||
})
|
||||
|
||||
it('failed getDisplayMedia after stop still disables loopback but does not reject', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const started = ctrl.start()
|
||||
await flush()
|
||||
ctrl.stop()
|
||||
port.displayRequests[0].reject(new Error('denied'))
|
||||
await expect(started).resolves.toBeUndefined()
|
||||
expect(port.loopbackEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('stream without an audio track is stopped and the start rejects', async () => {
|
||||
const { port, ctrl } = setup()
|
||||
const started = ctrl.start()
|
||||
await flush()
|
||||
const stream = new FakeStream(1, false)
|
||||
port.displayRequests[0].resolve(stream)
|
||||
await expect(started).rejects.toThrow('No audio track')
|
||||
expect(stream.stopped).toBe(true)
|
||||
expect(port.pipelines).toHaveLength(0)
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
})
|
||||
|
||||
it('stop is idempotent when nothing was started', () => {
|
||||
const { port, ctrl } = setup()
|
||||
expect(() => {
|
||||
ctrl.stop()
|
||||
ctrl.stop()
|
||||
}).not.toThrow()
|
||||
expect(ctrl.getState()).toBe('idle')
|
||||
expect(port.enableCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('cleanup errors are reported, not thrown', async () => {
|
||||
const port = new FakePort()
|
||||
const onCleanupError = vi.fn()
|
||||
const ctrl = new SystemAudioCaptureController(port, { onPcm: vi.fn(), onCleanupError })
|
||||
const started = ctrl.start()
|
||||
await flush()
|
||||
const stream = new FakeStream(1)
|
||||
stream.stop = () => {
|
||||
throw new Error('stop failed')
|
||||
}
|
||||
port.displayRequests[0].resolve(stream)
|
||||
await started
|
||||
expect(() => ctrl.stop()).not.toThrow()
|
||||
expect(onCleanupError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('float32ToPcm16', () => {
|
||||
it('clamps and scales samples to PCM16', () => {
|
||||
const out = new Int16Array(float32ToPcm16(new Float32Array([0, 1, -1, 2, -2, 0.5])))
|
||||
expect(Array.from(out)).toEqual([0, 32767, -32768, 32767, -32768, 16383])
|
||||
})
|
||||
})
|
||||
224
apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts
Normal file
224
apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: { functions: { invoke: jest.fn() } },
|
||||
}))
|
||||
|
||||
import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js'
|
||||
import { supabase } from '../src/lib/supabase'
|
||||
import {
|
||||
edgeFailureField,
|
||||
invokeEdgeFunction,
|
||||
readEdgeFunctionHttpFailure,
|
||||
type EdgeFunctionFailure,
|
||||
} from '../src/lib/edge-functions'
|
||||
import {
|
||||
accountDeletionFailureMessageKey,
|
||||
edgeAccountDeletionService,
|
||||
runAccountDeletion,
|
||||
type AccountDeletionService,
|
||||
} from '../src/features/account/account-deletion-service'
|
||||
|
||||
const mockInvoke = (supabase as unknown as { functions: { invoke: jest.Mock } }).functions.invoke
|
||||
|
||||
function jsonResponse(body: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Mirrors functions-js 2.103: non-2xx → { data: null, error: FunctionsHttpError(response) }. */
|
||||
function httpFailure(body: unknown, status: number): { data: null; error: FunctionsHttpError } {
|
||||
return { data: null, error: new FunctionsHttpError(jsonResponse(body, status)) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvoke.mockReset()
|
||||
})
|
||||
|
||||
describe('invokeEdgeFunction adapter', () => {
|
||||
test('decodes the JSON body of a 403 FunctionsHttpError instead of relying on data', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({
|
||||
error: 'Recent authentication is required',
|
||||
code: 'REAUTHENTICATION_REQUIRED',
|
||||
}, 403))
|
||||
|
||||
const result = await invokeEdgeFunction('account-delete', { confirmation: 'x' })
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'x' } })
|
||||
expect(result.ok).toBe(false)
|
||||
const failure = result as EdgeFunctionFailure
|
||||
expect(failure.status).toBe(403)
|
||||
expect(failure.body).toEqual({
|
||||
readable: true,
|
||||
payload: { error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' },
|
||||
})
|
||||
expect(edgeFailureField(failure, 'code')).toBe('REAUTHENTICATION_REQUIRED')
|
||||
expect(edgeFailureField(failure, 'error')).toBe('Recent authentication is required')
|
||||
expect(failure.message).toBe('Edge Function returned a non-2xx status code')
|
||||
})
|
||||
|
||||
test('reports an unreadable body without inventing a code', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
data: null,
|
||||
error: new FunctionsHttpError(new Response('<html>bad gateway</html>', { status: 502 })),
|
||||
})
|
||||
|
||||
const result = await invokeEdgeFunction('team-invite', {})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, status: 502, body: { readable: false } })
|
||||
expect(edgeFailureField(result as EdgeFunctionFailure, 'code')).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps fetch errors and thrown values as failures without an HTTP status', async () => {
|
||||
mockInvoke.mockResolvedValueOnce({ data: null, error: new FunctionsFetchError('offline') })
|
||||
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: null,
|
||||
body: null,
|
||||
})
|
||||
|
||||
const thrown = new TypeError('Network request failed')
|
||||
mockInvoke.mockRejectedValueOnce(thrown)
|
||||
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: null,
|
||||
body: null,
|
||||
message: 'Network request failed',
|
||||
cause: thrown,
|
||||
})
|
||||
})
|
||||
|
||||
test('returns data on success', async () => {
|
||||
mockInvoke.mockResolvedValue({ data: { success: true }, error: null })
|
||||
await expect(invokeEdgeFunction('account-delete', {})).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { success: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('readEdgeFunctionHttpFailure ignores errors without a Response context', async () => {
|
||||
await expect(readEdgeFunctionHttpFailure(new Error('x'))).resolves.toBeNull()
|
||||
await expect(readEdgeFunctionHttpFailure({ context: 'nope' })).resolves.toBeNull()
|
||||
await expect(readEdgeFunctionHttpFailure(null)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgeAccountDeletionService', () => {
|
||||
test('recognises REAUTHENTICATION_REQUIRED from a 403 response body', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({
|
||||
error: 'Recent authentication is required',
|
||||
code: 'REAUTHENTICATION_REQUIRED',
|
||||
}, 403))
|
||||
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'REAUTHENTICATION_REQUIRED',
|
||||
message: 'Recent authentication is required',
|
||||
})
|
||||
expect(mockInvoke).toHaveBeenCalledWith('account-delete', {
|
||||
body: { confirmation: 'DELETE_MY_ACCOUNT' },
|
||||
})
|
||||
})
|
||||
|
||||
test('recognises ACTIVE_SUBSCRIPTION from a 409 response body', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({
|
||||
error: 'Cancel the active subscription before deleting the account',
|
||||
code: 'ACTIVE_SUBSCRIPTION',
|
||||
}, 409))
|
||||
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'ACTIVE_SUBSCRIPTION',
|
||||
})
|
||||
})
|
||||
|
||||
test('maps a missing function (404) to SERVER_ENDPOINT_UNAVAILABLE', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({ code: 'NOT_FOUND', message: 'Requested function was not found' }, 404))
|
||||
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'SERVER_ENDPOINT_UNAVAILABLE',
|
||||
})
|
||||
})
|
||||
|
||||
test('treats a 2xx without success=true as REQUEST_FAILED and succeeds only on success=true', async () => {
|
||||
mockInvoke.mockResolvedValueOnce({ data: { success: false, error: 'nope' }, error: null })
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'REQUEST_FAILED',
|
||||
message: 'nope',
|
||||
})
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({ data: { success: true }, error: null })
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountDeletionFailureMessageKey', () => {
|
||||
test('always maps to translated copy, never raw server/SDK text', () => {
|
||||
expect(accountDeletionFailureMessageKey('REAUTHENTICATION_REQUIRED'))
|
||||
.toBe('mobile.account.reauthenticationRequired')
|
||||
expect(accountDeletionFailureMessageKey('SERVER_ENDPOINT_UNAVAILABLE'))
|
||||
.toBe('mobile.account.deleteUnavailable')
|
||||
expect(accountDeletionFailureMessageKey('ACTIVE_SUBSCRIPTION')).toBe('mobile.account.deleteFailed')
|
||||
expect(accountDeletionFailureMessageKey('REQUEST_FAILED')).toBe('mobile.account.deleteFailed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAccountDeletion ordering', () => {
|
||||
function service(result: Awaited<ReturnType<AccountDeletionService['deleteCurrentAccount']>>): AccountDeletionService {
|
||||
return { deleteCurrentAccount: jest.fn(async () => result) }
|
||||
}
|
||||
|
||||
test('a refused deletion leaves push registration and the local session untouched', async () => {
|
||||
const detachPushRegistration = jest.fn(async () => undefined)
|
||||
const purgeLocalSession = jest.fn(async () => undefined)
|
||||
|
||||
await expect(runAccountDeletion({
|
||||
deletionService: service({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }),
|
||||
detachPushRegistration,
|
||||
purgeLocalSession,
|
||||
})).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED' })
|
||||
|
||||
expect(detachPushRegistration).not.toHaveBeenCalled()
|
||||
expect(purgeLocalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('detaches push after server confirmation and before the local purge', async () => {
|
||||
const calls: string[] = []
|
||||
const deletionService: AccountDeletionService = {
|
||||
deleteCurrentAccount: jest.fn(async () => {
|
||||
calls.push('delete')
|
||||
return { ok: true } as const
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(runAccountDeletion({
|
||||
deletionService,
|
||||
detachPushRegistration: async () => { calls.push('detach') },
|
||||
purgeLocalSession: async () => { calls.push('purge') },
|
||||
})).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(calls).toEqual(['delete', 'detach', 'purge'])
|
||||
})
|
||||
|
||||
test('a push detach failure after deletion does not block the local purge', async () => {
|
||||
const purgeLocalSession = jest.fn(async () => undefined)
|
||||
|
||||
await expect(runAccountDeletion({
|
||||
deletionService: service({ ok: true }),
|
||||
detachPushRegistration: async () => { throw new Error('both push boundaries failed') },
|
||||
purgeLocalSession,
|
||||
})).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(purgeLocalSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('a local purge failure propagates to the caller', async () => {
|
||||
await expect(runAccountDeletion({
|
||||
deletionService: service({ ok: true }),
|
||||
detachPushRegistration: async () => undefined,
|
||||
purgeLocalSession: async () => { throw new Error('purge failed') },
|
||||
})).rejects.toThrow('purge failed')
|
||||
})
|
||||
})
|
||||
214
apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts
Normal file
214
apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
const mockClearHistory = jest.fn()
|
||||
const mockClearEntitlements = jest.fn()
|
||||
const mockClearPreferences = jest.fn()
|
||||
const mockCancelRecorder = jest.fn()
|
||||
const mockAcquireRecorder = jest.fn()
|
||||
const mockClearQueuedAudio = jest.fn()
|
||||
const mockRetainQueuedAudio = jest.fn()
|
||||
const mockClearActions = jest.fn()
|
||||
const mockClearGenerationKeys = jest.fn()
|
||||
const mockDeletePush = jest.fn()
|
||||
|
||||
jest.mock('../src/features/history/history-cache', () => ({
|
||||
clearAllHistoryCaches: () => mockClearHistory(),
|
||||
}))
|
||||
jest.mock('../src/lib/entitlement-context', () => ({
|
||||
clearAllEntitlementCaches: () => mockClearEntitlements(),
|
||||
}))
|
||||
jest.mock('../src/lib/preferences-context', () => ({
|
||||
clearAllUserPreferenceCaches: () => mockClearPreferences(),
|
||||
}))
|
||||
jest.mock('../src/lib/audio-recorder', () => ({
|
||||
audioRecorder: {
|
||||
cancel: () => mockCancelRecorder(),
|
||||
acquire: (owner: string) => mockAcquireRecorder(owner),
|
||||
},
|
||||
}))
|
||||
jest.mock('../src/features/recording/durable-processing-queue', () => ({
|
||||
clearAllQueuedAudio: () => mockClearQueuedAudio(),
|
||||
retainQueuedAudioOnlyForUser: (userId: string) => mockRetainQueuedAudio(userId),
|
||||
}))
|
||||
jest.mock('../src/features/actions/action-service', () => ({
|
||||
clearAllActionHistories: () => mockClearActions(),
|
||||
}))
|
||||
jest.mock('../src/features/templates', () => ({
|
||||
clearEveryGenerationIdempotencyKey: () => mockClearGenerationKeys(),
|
||||
}))
|
||||
jest.mock('../src/features/notifications/notification-native', () => ({
|
||||
deleteNativePushRegistration: () => mockDeletePush(),
|
||||
}))
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { purgeAllAccountLocalData } from '../src/lib/account-local-data'
|
||||
import { DISCARD_UNSYNCED_WORK } from '../src/lib/auth-transition-policy'
|
||||
import { RecorderBusyError } from '../src/lib/recorder/recorder-errors'
|
||||
import type {
|
||||
RecorderSession,
|
||||
RecordingRuntimeSnapshot,
|
||||
} from '../src/lib/recorder/recorder-types'
|
||||
import { stopLiveCaptureKeepingFile } from '../src/lib/retain-live-capture'
|
||||
import {
|
||||
createRetainedWorkOwnerStore,
|
||||
retainedAccountWork,
|
||||
retainedAccountWorkTestContract,
|
||||
} from '../src/lib/retained-account-work'
|
||||
|
||||
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const cacheOperations = [
|
||||
mockClearHistory,
|
||||
mockClearEntitlements,
|
||||
mockClearPreferences,
|
||||
mockClearActions,
|
||||
mockClearGenerationKeys,
|
||||
mockDeletePush,
|
||||
]
|
||||
|
||||
function snapshot(state: RecordingRuntimeSnapshot['state'], withFile: boolean): RecordingRuntimeSnapshot {
|
||||
return {
|
||||
state,
|
||||
recording: withFile
|
||||
? { uri: 'file:///c.wav', path: '/c.wav', fileName: 'c.wav', mimeType: 'audio/wav', size: 10, durationMs: 90 * 60_000 }
|
||||
: null,
|
||||
meetingId: null,
|
||||
interruptionReason: null,
|
||||
startedAtMs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSession(overrides: Partial<RecorderSession> = {}): jest.Mocked<RecorderSession> {
|
||||
return {
|
||||
owner: 'record',
|
||||
start: jest.fn(async () => undefined),
|
||||
pause: jest.fn(async () => undefined),
|
||||
resume: jest.fn(async () => undefined),
|
||||
stop: jest.fn(async () => snapshot('stopped', true).recording!),
|
||||
cleanup: jest.fn(async () => undefined),
|
||||
cancel: jest.fn(async () => undefined),
|
||||
restore: jest.fn(async () => snapshot('stopped', true)),
|
||||
...overrides,
|
||||
} as jest.Mocked<RecorderSession>
|
||||
}
|
||||
|
||||
describe('account purge keeps unsynced work on involuntary sign-out (redteam r3-16 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear()
|
||||
await retainedAccountWork.release()
|
||||
for (const operation of [...cacheOperations, mockCancelRecorder, mockClearQueuedAudio, mockRetainQueuedAudio]) {
|
||||
operation.mockReset().mockResolvedValue(undefined)
|
||||
}
|
||||
mockAcquireRecorder.mockReset().mockResolvedValue(fakeSession())
|
||||
})
|
||||
|
||||
it('purges caches but keeps the capture and the owner queue', async () => {
|
||||
await purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A })
|
||||
|
||||
for (const operation of cacheOperations) expect(operation).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancelRecorder).not.toHaveBeenCalled()
|
||||
expect(mockClearQueuedAudio).not.toHaveBeenCalled()
|
||||
expect(mockRetainQueuedAudio).toHaveBeenCalledWith(USER_A)
|
||||
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe(USER_A)
|
||||
})
|
||||
|
||||
it('discards everything and releases the retention marker on a discard boundary', async () => {
|
||||
await retainedAccountWork.retain(USER_A)
|
||||
await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
|
||||
|
||||
expect(mockCancelRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1)
|
||||
expect(retainedAccountWork.current()).toBeNull()
|
||||
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('runs a discard requested during a retain run instead of sharing it', async () => {
|
||||
let finishRetain: (() => void) | null = null
|
||||
mockRetainQueuedAudio.mockReturnValueOnce(new Promise<void>((resolve) => {
|
||||
finishRetain = resolve
|
||||
}))
|
||||
const retain = purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A })
|
||||
const discard = purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
|
||||
expect(discard).not.toBe(retain)
|
||||
finishRetain?.()
|
||||
await Promise.all([retain, discard])
|
||||
|
||||
expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancelRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(retainedAccountWork.current()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopLiveCaptureKeepingFile (redteam r3-16 #2)', () => {
|
||||
it('stops an in-process capture and keeps its reattachable file', async () => {
|
||||
const session = fakeSession()
|
||||
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
|
||||
expect(session.stop).toHaveBeenCalledTimes(1)
|
||||
expect(session.cancel).not.toHaveBeenCalled()
|
||||
expect(session.cleanup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reattaches a capture that outlived a JS restart before stopping it', async () => {
|
||||
const session = fakeSession({
|
||||
stop: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Cannot stop recorder while it is idle'))
|
||||
.mockResolvedValueOnce(snapshot('stopped', true).recording),
|
||||
restore: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(snapshot('recording', false))
|
||||
.mockResolvedValueOnce(snapshot('stopped', true)),
|
||||
})
|
||||
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
|
||||
expect(session.stop).toHaveBeenCalledTimes(2)
|
||||
expect(session.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to discarding a capture the backend cannot hand back', async () => {
|
||||
const session = fakeSession({ restore: jest.fn(async () => snapshot('idle', false)) })
|
||||
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
|
||||
expect(session.cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('discards a transient Talk capture through its own owner session', async () => {
|
||||
const talk = fakeSession({ owner: 'talk' })
|
||||
const acquire = jest.fn(async (owner: string) => {
|
||||
if (owner === 'record') throw new RecorderBusyError('talk')
|
||||
return talk
|
||||
})
|
||||
await stopLiveCaptureKeepingFile({ acquire })
|
||||
expect(acquire).toHaveBeenLastCalledWith('talk')
|
||||
expect(talk.cancel).toHaveBeenCalledTimes(1)
|
||||
expect(talk.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retained work owner store (redteam r3-16 #2)', () => {
|
||||
it('persists the owner across a restart and ignores a stale load', async () => {
|
||||
const values = new Map<string, string>()
|
||||
let resolveRead: ((value: string | null) => void) | null = null
|
||||
const storage = {
|
||||
getItem: jest.fn((key: string) => {
|
||||
const valueAtRead = values.get(key) ?? null
|
||||
return new Promise<string | null>((resolve) => {
|
||||
resolveRead = () => resolve(valueAtRead)
|
||||
})
|
||||
}),
|
||||
setItem: jest.fn(async (key: string, value: string) => { values.set(key, value) }),
|
||||
removeItem: jest.fn(async (key: string) => { values.delete(key) }),
|
||||
}
|
||||
const first = createRetainedWorkOwnerStore(storage)
|
||||
await first.retain('owner-a')
|
||||
|
||||
const restarted = createRetainedWorkOwnerStore(storage)
|
||||
const loading = restarted.load()
|
||||
resolveRead?.(null)
|
||||
await expect(loading).resolves.toBe('owner-a')
|
||||
expect(restarted.current()).toBe('owner-a')
|
||||
|
||||
const staleLoad = createRetainedWorkOwnerStore(storage)
|
||||
const pending = staleLoad.load()
|
||||
await staleLoad.release()
|
||||
resolveRead?.(null)
|
||||
await expect(pending).resolves.toBeNull()
|
||||
expect(staleLoad.current()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -102,19 +102,16 @@ describe('mobile auth redirect boundary', () => {
|
|||
expect(exchangeCodeForSession).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('accepts a complete legacy token pair once and rejects partial credentials', async () => {
|
||||
// redteam r3-16 #1: 토큰 쌍 콜백(implicit flow)은 로그인 CSRF 경로라 완전한 쌍이어도 세션으로 바꾸지 않는다.
|
||||
// 이전 계약("완전한 레거시 쌍은 한 번 받아들인다")을 대체한다 — auth-redteam-r3-16.test.tsx 와 같은 계약.
|
||||
it('rejects token-pair callbacks, complete or partial, without touching the session', async () => {
|
||||
const { operations, setSession } = createOperations()
|
||||
const complete = createAuthRedirectHandler(operations)
|
||||
const url = 'd3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery'
|
||||
|
||||
await expect(complete(url)).resolves.toBe('recovery')
|
||||
await expect(complete(url)).resolves.toBe('recovery')
|
||||
expect(setSession).toHaveBeenCalledTimes(1)
|
||||
expect(setSession).toHaveBeenCalledWith({
|
||||
access_token: 'access',
|
||||
refresh_token: 'refresh',
|
||||
})
|
||||
await expect(complete('d3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery'))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
await expect(complete('d3ro-voice://auth-callback#access_token=partial'))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
expect(setSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
298
apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx
Normal file
298
apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import React from 'react'
|
||||
import { Linking } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { AuthChangeEvent, Session } from '@supabase/supabase-js'
|
||||
|
||||
const mockGetSession = jest.fn()
|
||||
const mockOnAuthStateChange = jest.fn()
|
||||
const mockStopAutoRefresh = jest.fn()
|
||||
const mockStartAutoRefresh = jest.fn()
|
||||
const mockSetSession = jest.fn()
|
||||
const mockExchangeCodeForSession = jest.fn()
|
||||
const mockPurgeAccountLocalData = jest.fn()
|
||||
const mockClearSecureAuthStorage = jest.fn()
|
||||
|
||||
jest.mock('../src/lib/supabase', () => ({
|
||||
isSupabaseConfigured: () => true,
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||
onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args),
|
||||
stopAutoRefresh: (...args: unknown[]) => mockStopAutoRefresh(...args),
|
||||
startAutoRefresh: (...args: unknown[]) => mockStartAutoRefresh(...args),
|
||||
setSession: (...args: unknown[]) => mockSetSession(...args),
|
||||
exchangeCodeForSession: (...args: unknown[]) => mockExchangeCodeForSession(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
jest.mock('../src/lib/account-local-data', () => ({
|
||||
purgeAllAccountLocalData: (...args: unknown[]) => mockPurgeAccountLocalData(...args),
|
||||
}))
|
||||
jest.mock('../src/lib/secure-auth-storage', () => ({
|
||||
clearAllSecureAuthStorage: (...args: unknown[]) => mockClearSecureAuthStorage(...args),
|
||||
}))
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { AuthProvider, useAuth } from '../src/lib/auth-context'
|
||||
import { createAuthRedirectHandler } from '../src/lib/auth-redirect'
|
||||
import {
|
||||
DISCARD_UNSYNCED_WORK,
|
||||
planAuthTransition,
|
||||
type AuthTransitionFacts,
|
||||
} from '../src/lib/auth-transition-policy'
|
||||
import {
|
||||
retainedAccountWork,
|
||||
retainedAccountWorkTestContract,
|
||||
} from '../src/lib/retained-account-work'
|
||||
|
||||
type AuthSnapshot = ReturnType<typeof useAuth>
|
||||
type AuthCallback = (event: AuthChangeEvent, session: Session | null) => void
|
||||
type UrlListener = (event: { url: string }) => void
|
||||
|
||||
const ATTACKER_LINK = 'd3ro-voice://auth-callback#access_token=attacker-access&refresh_token=attacker-refresh'
|
||||
|
||||
let latest: AuthSnapshot
|
||||
let authCallback: AuthCallback | null = null
|
||||
let urlListener: UrlListener | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let storedSession: Session | null = null
|
||||
|
||||
function session(userId: string): Session {
|
||||
return {
|
||||
access_token: `access-${userId}`,
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
expires_at: 4_000_000_000,
|
||||
refresh_token: `refresh-${userId}`,
|
||||
user: { id: userId },
|
||||
} as unknown as Session
|
||||
}
|
||||
|
||||
function Probe(): null {
|
||||
latest = useAuth()
|
||||
return null
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 12; index += 1) await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function mount(restoredSession: Session | null): Promise<void> {
|
||||
storedSession = restoredSession
|
||||
await act(async () => {
|
||||
renderer = create(<AuthProvider><Probe /></AuthProvider>)
|
||||
})
|
||||
await flush()
|
||||
}
|
||||
|
||||
function emit(event: AuthChangeEvent, nextSession: Session | null): void {
|
||||
storedSession = nextSession
|
||||
if (authCallback === null) throw new Error('auth callback is not subscribed')
|
||||
authCallback(event, nextSession)
|
||||
}
|
||||
|
||||
function facts(overrides: Partial<AuthTransitionFacts>): AuthTransitionFacts {
|
||||
return {
|
||||
previousUserId: null,
|
||||
nextUserId: null,
|
||||
retainedOwnerUserId: null,
|
||||
forcePurge: false,
|
||||
cleanupPending: false,
|
||||
explicit: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile login CSRF via implicit token callback (redteam r3-16 #1)', () => {
|
||||
it('never turns a token-pair callback into a session', async () => {
|
||||
const setSession = jest.fn(async () => ({ data: { session: null, user: null }, error: null }))
|
||||
const exchangeCodeForSession = jest.fn()
|
||||
const complete = createAuthRedirectHandler({
|
||||
exchangeCodeForSession,
|
||||
setSession,
|
||||
} as unknown as Parameters<typeof createAuthRedirectHandler>[0])
|
||||
|
||||
await expect(complete(ATTACKER_LINK)).rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
await expect(complete(`${ATTACKER_LINK}&type=recovery`))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
await expect(complete('d3ro-voice://auth-callback?code=c#access_token=a&refresh_token=r'))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
expect(setSession).not.toHaveBeenCalled()
|
||||
expect(exchangeCodeForSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the signed-in victim and their local data when the link is opened', async () => {
|
||||
authCallback = null
|
||||
urlListener = null
|
||||
mockGetSession.mockReset().mockImplementation(async () => ({
|
||||
data: { session: storedSession },
|
||||
error: null,
|
||||
}))
|
||||
mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => {
|
||||
authCallback = callback
|
||||
return { data: { subscription: { unsubscribe: jest.fn() } } }
|
||||
})
|
||||
mockSetSession.mockReset().mockResolvedValue({ data: { session: null, user: null }, error: null })
|
||||
mockPurgeAccountLocalData.mockReset().mockResolvedValue(undefined)
|
||||
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null)
|
||||
jest.spyOn(Linking, 'addEventListener').mockImplementation(((_type: string, listener: UrlListener) => {
|
||||
urlListener = listener
|
||||
return { remove: jest.fn() }
|
||||
}) as unknown as typeof Linking.addEventListener)
|
||||
|
||||
await mount(session('victim'))
|
||||
expect(latest.user?.id).toBe('victim')
|
||||
|
||||
await act(async () => {
|
||||
urlListener?.({ url: ATTACKER_LINK })
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockSetSession).not.toHaveBeenCalled()
|
||||
expect(mockPurgeAccountLocalData).not.toHaveBeenCalled()
|
||||
expect(latest.user?.id).toBe('victim')
|
||||
expect(latest.authError).toBe('callback_failed')
|
||||
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth transition policy (redteam r3-16 #2)', () => {
|
||||
it('retains the owner work on involuntary session loss', () => {
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a' }))).toEqual({
|
||||
purge: true,
|
||||
unsyncedWork: { kind: 'retain', ownerUserId: 'a' },
|
||||
})
|
||||
})
|
||||
|
||||
it('discards on explicit logout and on a different account', () => {
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a', explicit: true, forcePurge: true })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'b' })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'b' })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
})
|
||||
|
||||
it('lets the owner come back without a purge and releases the marker', () => {
|
||||
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'a' })))
|
||||
.toEqual({ purge: false, releaseRetainedWork: true })
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'a' })))
|
||||
.toEqual({ purge: false, releaseRetainedWork: false })
|
||||
})
|
||||
|
||||
it('keeps retained work across a cold boot without a session, discards unowned work', () => {
|
||||
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', forcePurge: true })))
|
||||
.toEqual({ purge: true, unsyncedWork: { kind: 'retain', ownerUserId: 'a' } })
|
||||
expect(planAuthTransition(facts({ forcePurge: true })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthProvider keeps unsynced recordings on involuntary sign-out (redteam r3-16 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
authCallback = null
|
||||
renderer = null
|
||||
storedSession = null
|
||||
await AsyncStorage.clear()
|
||||
await retainedAccountWork.release()
|
||||
mockGetSession.mockReset().mockImplementation(async () => ({
|
||||
data: { session: storedSession },
|
||||
error: null,
|
||||
}))
|
||||
mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => {
|
||||
authCallback = callback
|
||||
return { data: { subscription: { unsubscribe: jest.fn() } } }
|
||||
})
|
||||
mockStopAutoRefresh.mockReset().mockResolvedValue(undefined)
|
||||
mockStartAutoRefresh.mockReset().mockResolvedValue(undefined)
|
||||
mockClearSecureAuthStorage.mockReset().mockResolvedValue(undefined)
|
||||
// The real purge records the retained owner; mirror that contract here.
|
||||
mockPurgeAccountLocalData.mockReset().mockImplementation(async (disposition?: {
|
||||
kind: 'discard' | 'retain'
|
||||
ownerUserId?: string
|
||||
}) => {
|
||||
if (disposition?.kind === 'retain' && disposition.ownerUserId !== undefined) {
|
||||
await retainedAccountWork.retain(disposition.ownerUserId)
|
||||
} else {
|
||||
await retainedAccountWork.release()
|
||||
}
|
||||
})
|
||||
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (renderer !== null) act(() => renderer?.unmount())
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('retains A work on SIGNED_OUT and resumes it without a purge when A returns', async () => {
|
||||
await mount(session('user-a'))
|
||||
act(() => emit('SIGNED_OUT', null))
|
||||
await flush()
|
||||
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1)
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith({ kind: 'retain', ownerUserId: 'user-a' })
|
||||
expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1)
|
||||
expect(latest.user).toBeNull()
|
||||
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe('user-a')
|
||||
|
||||
act(() => emit('SIGNED_IN', session('user-a')))
|
||||
await flush()
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1)
|
||||
expect(latest.user?.id).toBe('user-a')
|
||||
expect(retainedAccountWork.current()).toBeNull()
|
||||
})
|
||||
|
||||
it('discards retained work before a different account is committed', async () => {
|
||||
await mount(session('user-a'))
|
||||
act(() => emit('SIGNED_OUT', null))
|
||||
await flush()
|
||||
|
||||
let finishDiscard: (() => void) | null = null
|
||||
mockPurgeAccountLocalData.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishDiscard = resolve
|
||||
}))
|
||||
act(() => emit('SIGNED_IN', session('user-b')))
|
||||
await flush()
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK)
|
||||
expect(latest.user).toBeNull()
|
||||
|
||||
finishDiscard?.()
|
||||
await flush()
|
||||
expect(latest.user?.id).toBe('user-b')
|
||||
})
|
||||
|
||||
it('keeps retained work across a cold boot with no session', async () => {
|
||||
await retainedAccountWork.retain('user-a')
|
||||
await mount(null)
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenCalledWith({ kind: 'retain', ownerUserId: 'user-a' })
|
||||
})
|
||||
|
||||
it('still discards everything on an explicit logout', async () => {
|
||||
await mount(session('user-a'))
|
||||
await act(async () => {
|
||||
await latest.purgeLocalSession()
|
||||
})
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK)
|
||||
})
|
||||
|
||||
it('discards retained work when a racing newer session belongs to another account', async () => {
|
||||
await mount(session('user-a'))
|
||||
storedSession = session('user-b')
|
||||
act(() => {
|
||||
authCallback?.('SIGNED_OUT', null)
|
||||
})
|
||||
await flush()
|
||||
expect(mockPurgeAccountLocalData.mock.calls.map(([disposition]) => disposition)).toEqual([
|
||||
{ kind: 'retain', ownerUserId: 'user-a' },
|
||||
DISCARD_UNSYNCED_WORK,
|
||||
])
|
||||
expect(latest.user?.id).toBe('user-b')
|
||||
expect(mockClearSecureAuthStorage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { FileSystem } from 'react-native-file-access'
|
||||
import type {
|
||||
AudioPipelineResult,
|
||||
LocalAudioInput,
|
||||
} from '../src/features/import/audio-import-types'
|
||||
|
||||
const mockProcessAudioInput = jest.fn<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
|
||||
|
||||
jest.mock('../src/features/import/audio-transcription-service', () => ({
|
||||
processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options),
|
||||
}))
|
||||
jest.mock('../src/features/meetings/meetings-service', () => ({
|
||||
failMeetingRecording: jest.fn(async () => undefined),
|
||||
markMeetingProcessingFailure: jest.fn(async () => undefined),
|
||||
queueMeetingRecording: jest.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
import {
|
||||
clearQueuedAudioForUser,
|
||||
durableQueueTestContract,
|
||||
resumeQueuedAudioForUser,
|
||||
retainQueuedAudioOnlyForUser,
|
||||
type DurableQueueItem,
|
||||
} from '../src/features/recording/durable-processing-queue'
|
||||
|
||||
const START_MS = 1_700_000_000_000
|
||||
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||
const USER_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const mockFileSystem = FileSystem as typeof FileSystem & { filesystem: Map<string, string> }
|
||||
|
||||
function queued(
|
||||
userId: string,
|
||||
suffix: string,
|
||||
overrides: Partial<DurableQueueItem> = {},
|
||||
): DurableQueueItem {
|
||||
const path = `${durableQueueTestContract.queueDirectory}/queued-${suffix}.wav`
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: suffix,
|
||||
userId,
|
||||
meetingId: null,
|
||||
path,
|
||||
uri: `file://${path}`,
|
||||
fileName: `${suffix}.wav`,
|
||||
mimeType: 'audio/wav',
|
||||
sizeBytes: 5,
|
||||
durationMs: 100,
|
||||
source: 'recording',
|
||||
languageCode: 'ko',
|
||||
status: 'retry',
|
||||
phase: null,
|
||||
attempts: 1,
|
||||
uploadedBytes: 0,
|
||||
nextAttemptAtMs: START_MS + 5_000,
|
||||
lastErrorCode: 'upload',
|
||||
lastErrorMessage: null,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function result(): AudioPipelineResult {
|
||||
return {
|
||||
historyId: null,
|
||||
audioFileId: 'audio-file',
|
||||
meetingId: null,
|
||||
processingJobId: null,
|
||||
transcript: 'ok',
|
||||
provider: 'test',
|
||||
language: 'ko',
|
||||
durationSeconds: 1,
|
||||
deduplicated: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(items: DurableQueueItem[]): Promise<void> {
|
||||
for (const item of items) mockFileSystem.filesystem.set(item.path, 'audio')
|
||||
await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify(items))
|
||||
}
|
||||
|
||||
async function stored(): Promise<DurableQueueItem[]> {
|
||||
return durableQueueTestContract.parseQueue(
|
||||
await AsyncStorage.getItem(durableQueueTestContract.storageKey),
|
||||
)
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
describe('durable queue keeps the signed-out owner recordings (redteam r3-16 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers({ now: START_MS })
|
||||
await AsyncStorage.clear()
|
||||
mockFileSystem.filesystem.clear()
|
||||
mockProcessAudioInput.mockReset().mockResolvedValue(result())
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const userId of [USER_A, USER_B]) {
|
||||
await clearQueuedAudioForUser(userId).catch(() => undefined)
|
||||
}
|
||||
await settle()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('keeps the owner items and files, discards other accounts', async () => {
|
||||
const ownerRetry = queued(USER_A, 'aaa001')
|
||||
const ownerPending = queued(USER_A, 'aaa002', { status: 'pending', attempts: 0, nextAttemptAtMs: START_MS })
|
||||
const other = queued(USER_B, 'bbb001')
|
||||
await seed([ownerRetry, ownerPending, other])
|
||||
|
||||
await retainQueuedAudioOnlyForUser(USER_A)
|
||||
|
||||
expect((await stored()).map(item => item.id).sort()).toEqual(['aaa001', 'aaa002'])
|
||||
expect(mockFileSystem.filesystem.has(ownerRetry.path)).toBe(true)
|
||||
expect(mockFileSystem.filesystem.has(ownerPending.path)).toBe(true)
|
||||
expect(mockFileSystem.filesystem.has(other.path)).toBe(false)
|
||||
})
|
||||
|
||||
test('does not spend retries while the owner is signed out, resumes on sign-in', async () => {
|
||||
const item = queued(USER_A, 'aaa003', { nextAttemptAtMs: START_MS })
|
||||
await seed([item])
|
||||
|
||||
await retainQueuedAudioOnlyForUser(USER_A)
|
||||
await jest.advanceTimersByTimeAsync(60 * 60_000)
|
||||
await settle()
|
||||
expect(mockProcessAudioInput).not.toHaveBeenCalled()
|
||||
expect((await stored())[0]?.attempts).toBe(1)
|
||||
|
||||
await resumeQueuedAudioForUser(USER_A)
|
||||
await settle()
|
||||
expect(mockProcessAudioInput).toHaveBeenCalledTimes(1)
|
||||
expect(await stored()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
const mockRealtimeOn = jest.fn()
|
||||
const mockRealtimeSubscribe = jest.fn()
|
||||
const mockRemoveChannel = jest.fn(async () => 'ok')
|
||||
const mockRealtimeChannel: Record<string, unknown> = {
|
||||
on: mockRealtimeOn,
|
||||
subscribe: mockRealtimeSubscribe,
|
||||
}
|
||||
mockRealtimeOn.mockReturnValue(mockRealtimeChannel)
|
||||
mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel)
|
||||
|
||||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
from: jest.fn(),
|
||||
channel: jest.fn(() => mockRealtimeChannel),
|
||||
removeChannel: (...args: unknown[]) => mockRemoveChannel(...(args as [])),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
type ForegroundStatePort,
|
||||
type IntervalPort,
|
||||
KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS,
|
||||
knowledgeRealtimeBindings,
|
||||
startForegroundReconciliation,
|
||||
} from '../src/features/knowledge/knowledge-realtime'
|
||||
import { subscribeToKnowledgeDocuments } from '../src/features/knowledge/knowledge-service'
|
||||
|
||||
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
interface FakeForeground extends ForegroundStatePort {
|
||||
set: (active: boolean) => void
|
||||
listenerCount: () => number
|
||||
}
|
||||
|
||||
function fakeForeground(initial: boolean): FakeForeground {
|
||||
let active = initial
|
||||
const listeners = new Set<(active: boolean) => void>()
|
||||
return {
|
||||
isActive: () => active,
|
||||
onChange: (listener) => {
|
||||
listeners.add(listener)
|
||||
return { remove: () => { listeners.delete(listener) } }
|
||||
},
|
||||
set: (next) => {
|
||||
active = next
|
||||
for (const listener of listeners) listener(next)
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeInterval extends IntervalPort {
|
||||
tick: () => void
|
||||
cleared: () => boolean
|
||||
lastMs: () => number | null
|
||||
}
|
||||
|
||||
function fakeInterval(): FakeInterval {
|
||||
let callback: (() => void) | null = null
|
||||
let cleared = false
|
||||
let lastMs: number | null = null
|
||||
const handle = 1 as unknown as ReturnType<typeof setInterval>
|
||||
return {
|
||||
set: (cb, ms) => { callback = cb; lastMs = ms; return handle },
|
||||
clear: () => { cleared = true; callback = null },
|
||||
tick: () => { callback?.() },
|
||||
cleared: () => cleared,
|
||||
lastMs: () => lastMs,
|
||||
}
|
||||
}
|
||||
|
||||
function subscribedBindings(): Array<{ event: string, filter?: string, table: string }> {
|
||||
return mockRealtimeOn.mock.calls.map((call) => call[1] as {
|
||||
event: string
|
||||
filter?: string
|
||||
table: string
|
||||
})
|
||||
}
|
||||
|
||||
describe('knowledge realtime policy (redteam r3-20)', () => {
|
||||
beforeEach(() => {
|
||||
mockRealtimeOn.mockClear()
|
||||
mockRealtimeSubscribe.mockClear()
|
||||
mockRemoveChannel.mockClear()
|
||||
})
|
||||
|
||||
it('never subscribes to unfiltered DELETE events on knowledge_documents', () => {
|
||||
const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
|
||||
reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() },
|
||||
})
|
||||
const events = subscribedBindings().map((binding) => binding.event)
|
||||
expect(events).not.toContain('DELETE')
|
||||
expect(events).not.toContain('*')
|
||||
expect(events).toEqual(['INSERT', 'UPDATE'])
|
||||
void subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('narrows INSERT/UPDATE to the owner when a user id is supplied', () => {
|
||||
const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
|
||||
userId: USER_A,
|
||||
reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() },
|
||||
})
|
||||
expect(subscribedBindings()).toEqual([
|
||||
expect.objectContaining({ event: 'INSERT', filter: `user_id=eq.${USER_A}` }),
|
||||
expect.objectContaining({ event: 'UPDATE', filter: `user_id=eq.${USER_A}` }),
|
||||
])
|
||||
void subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('rejects a malformed owner id instead of interpolating it into the filter', () => {
|
||||
expect(() => subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
|
||||
userId: 'x,user_id=neq.0',
|
||||
})).toThrow()
|
||||
expect(mockRealtimeOn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles deletions by foreground polling and stops on unsubscribe', async () => {
|
||||
const onChanged = jest.fn()
|
||||
const foreground = fakeForeground(true)
|
||||
const interval = fakeInterval()
|
||||
const subscription = subscribeToKnowledgeDocuments(onChanged, jest.fn(), {
|
||||
reconciliation: { foreground, interval },
|
||||
})
|
||||
expect(interval.lastMs()).toBe(KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS)
|
||||
|
||||
interval.tick()
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
await subscription.unsubscribe()
|
||||
expect(interval.cleared()).toBe(true)
|
||||
expect(foreground.listenerCount()).toBe(0)
|
||||
expect(mockRemoveChannel).toHaveBeenCalledWith(mockRealtimeChannel)
|
||||
interval.tick()
|
||||
foreground.set(false)
|
||||
foreground.set(true)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pauses polling in the background and reconciles once on resume', () => {
|
||||
const onReconcile = jest.fn()
|
||||
const foreground = fakeForeground(true)
|
||||
const interval = fakeInterval()
|
||||
const reconciliation = startForegroundReconciliation(onReconcile, { foreground, interval })
|
||||
|
||||
foreground.set(false)
|
||||
interval.tick()
|
||||
expect(onReconcile).not.toHaveBeenCalled()
|
||||
|
||||
foreground.set(true)
|
||||
expect(onReconcile).toHaveBeenCalledTimes(1)
|
||||
foreground.set(true)
|
||||
expect(onReconcile).toHaveBeenCalledTimes(1)
|
||||
|
||||
interval.tick()
|
||||
expect(onReconcile).toHaveBeenCalledTimes(2)
|
||||
reconciliation.stop()
|
||||
reconciliation.stop()
|
||||
})
|
||||
|
||||
it('builds bindings without DELETE and without a filter when no owner is given', () => {
|
||||
expect(knowledgeRealtimeBindings()).toEqual([
|
||||
{ event: 'INSERT', schema: 'public', table: 'knowledge_documents' },
|
||||
{ event: 'UPDATE', schema: 'public', table: 'knowledge_documents' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -235,9 +235,11 @@ describe('knowledge edge contracts', () => {
|
|||
})).rejects.toMatchObject({ code: 'invalid-response' })
|
||||
})
|
||||
|
||||
it('subscribes to document inserts, updates, and deletes through RLS realtime', () => {
|
||||
// redteam r3-20: 필터가 걸린 postgres_changes 는 DELETE 를 보내지 않고, 필터 없는 DELETE 구독은 남의 행 id 를 흘린다.
|
||||
// 삭제는 knowledge-realtime 의 다른 경로로 받는다 — knowledge-realtime-redteam-r3-20.test.ts 와 같은 계약.
|
||||
it('subscribes to document inserts and updates through RLS realtime, never to DELETE', () => {
|
||||
subscribeToKnowledgeDocuments(jest.fn(), jest.fn())
|
||||
const events = mockRealtimeOn.mock.calls.map((call) => (call[1] as { event: string }).event)
|
||||
expect(events).toEqual(['INSERT', 'UPDATE', 'DELETE'])
|
||||
expect(events).toEqual(['INSERT', 'UPDATE'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
rpc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import type { Meeting, MeetingStatus } from '@d3ro/api-client';
|
||||
import { supabase } from '../src/lib/supabase';
|
||||
import {
|
||||
beginMeetingRecording,
|
||||
cancelMeetingRecording,
|
||||
completeMeetingProcessing,
|
||||
failMeetingRecording,
|
||||
queueMeetingRecording,
|
||||
} from '../src/features/meetings/meetings-service';
|
||||
import {
|
||||
confirmsMeetingTransition,
|
||||
expectedMeetingStates,
|
||||
} from '../src/features/meetings/meeting-recording-state-policy';
|
||||
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555';
|
||||
const MEETING_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const AUDIO_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`;
|
||||
const mockRpc = supabase.rpc as jest.Mock;
|
||||
|
||||
function meeting(status: MeetingStatus, userId = USER_ID): Meeting {
|
||||
return {
|
||||
id: MEETING_ID,
|
||||
user_id: userId,
|
||||
team_id: null,
|
||||
title: 'Re-recorded meeting',
|
||||
status,
|
||||
started_at: '2026-08-21T00:00:00.000Z',
|
||||
ended_at: '2026-08-21T00:01:00.000Z',
|
||||
duration_ms: 60_000,
|
||||
raw_transcript: 'earlier transcript',
|
||||
edited_transcript: null,
|
||||
minutes_markdown: 'earlier minutes',
|
||||
minutes_json: null,
|
||||
stt_model: 'whisper',
|
||||
llm_model: null,
|
||||
stt_latency_ms: 100,
|
||||
llm_latency_ms: null,
|
||||
error_message: null,
|
||||
audio_storage_key: `${USER_ID}/imports/earlier.wav`,
|
||||
created_at: '2026-08-21T00:00:00.000Z',
|
||||
updated_at: '2026-08-21T00:01:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('re-record failure paths (server restores a meeting with content to completed)', () => {
|
||||
beforeEach(() => mockRpc.mockReset());
|
||||
|
||||
test('cancelMeetingRecording accepts the restored completed meeting', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID))
|
||||
.resolves.toMatchObject({ status: 'completed', raw_transcript: 'earlier transcript' });
|
||||
expect(mockRpc).toHaveBeenCalledWith('mobile_cancel_meeting_recording', { p_meeting_id: MEETING_ID });
|
||||
});
|
||||
|
||||
test('failMeetingRecording accepts the restored completed meeting', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'Queued audio processing was cancelled'))
|
||||
.resolves.toMatchObject({ status: 'completed' });
|
||||
expect(mockRpc).toHaveBeenCalledWith('mobile_fail_meeting_recording', {
|
||||
p_meeting_id: MEETING_ID,
|
||||
p_error_message: 'Queued audio processing was cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
test('a first recording still confirms the error outcome', async () => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting('error'), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('error'), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'error' });
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).resolves.toMatchObject({ status: 'error' });
|
||||
});
|
||||
|
||||
test.each<MeetingStatus>(['recording', 'processing'])(
|
||||
'cancel/fail still reject a meeting left in %s',
|
||||
async (status) => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting(status), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting(status), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
|
||||
code: 'server',
|
||||
message: 'Meeting cancellation was not confirmed',
|
||||
});
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({
|
||||
code: 'server',
|
||||
message: 'Meeting error state was not confirmed',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('cancel/fail still reject a row owned by another user', async () => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting('completed', OTHER_USER_ID), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('error', OTHER_USER_ID), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server' });
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server' });
|
||||
});
|
||||
|
||||
test('the other transitions keep their single confirmed state', async () => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('error'), error: null });
|
||||
await expect(beginMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
|
||||
message: 'Meeting recording state was not confirmed',
|
||||
});
|
||||
await expect(queueMeetingRecording(USER_ID, MEETING_ID, 1_000)).rejects.toMatchObject({
|
||||
message: 'Queued meeting state was not confirmed',
|
||||
});
|
||||
await expect(completeMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, {
|
||||
transcript: 't',
|
||||
language: 'ko',
|
||||
provider: 'whisper',
|
||||
durationMs: 1_000,
|
||||
sttLatencyMs: 10,
|
||||
})).rejects.toMatchObject({ message: 'Meeting completion was not confirmed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('meeting recording state policy', () => {
|
||||
test('lists the states each transition may produce', () => {
|
||||
expect(expectedMeetingStates('recording-started')).toEqual(['recording']);
|
||||
expect(expectedMeetingStates('recording-queued')).toEqual(['processing']);
|
||||
expect(expectedMeetingStates('processing-completed')).toEqual(['completed']);
|
||||
expect(expectedMeetingStates('capture-abandoned')).toEqual(['error', 'completed']);
|
||||
});
|
||||
|
||||
test('confirms only the caller-owned meeting in an expected state', () => {
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'completed' }, USER_ID, 'capture-abandoned')).toBe(true);
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(true);
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'processing' }, USER_ID, 'capture-abandoned')).toBe(false);
|
||||
expect(confirmsMeetingTransition({ user_id: OTHER_USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(false);
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'processing-completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const mockMaybeSingle = jest.fn(async () => ({
|
||||
data: null,
|
||||
error: { message: 'network down', code: 'NETWORK' },
|
||||
}))
|
||||
|
||||
jest.mock('../src/lib/auth-context', () => ({
|
||||
useAuth: () => ({ user: { id: '22222222-2222-4222-8222-222222222222' } }),
|
||||
}))
|
||||
|
||||
jest.mock('../src/lib/supabase', () => {
|
||||
const channel = {
|
||||
on: () => channel,
|
||||
subscribe: () => channel,
|
||||
}
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: () => builder,
|
||||
maybeSingle: () => mockMaybeSingle(),
|
||||
}
|
||||
return {
|
||||
supabase: {
|
||||
from: () => builder,
|
||||
channel: () => channel,
|
||||
removeChannel: jest.fn(async () => 'ok'),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
MobilePreferencesProvider,
|
||||
useMobilePreferences,
|
||||
} from '../src/lib/preferences-context'
|
||||
|
||||
type RetrySync = () => Promise<void>
|
||||
|
||||
const loadingHistory: boolean[] = []
|
||||
let screenMounts = 0
|
||||
let latestRetrySync: RetrySync | null = null
|
||||
let latestSyncStatus: string | null = null
|
||||
|
||||
function Screen(): React.ReactElement | null {
|
||||
useEffect(() => {
|
||||
screenMounts += 1
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function Gate(): React.ReactElement | null {
|
||||
const { loading, retrySync, syncStatus } = useMobilePreferences()
|
||||
loadingHistory.push(loading)
|
||||
latestRetrySync = retrySync
|
||||
latestSyncStatus = syncStatus
|
||||
// Mirrors App.tsx: while loading, the navigation tree is replaced by a boot screen.
|
||||
return loading ? null : <Screen />
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('mobile preferences background resync (redteam r3-18)', () => {
|
||||
let appStateListeners: Array<(state: AppStateStatus) => void>
|
||||
let renderer: ReactTestRenderer | null
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear()
|
||||
loadingHistory.length = 0
|
||||
screenMounts = 0
|
||||
latestRetrySync = null
|
||||
latestSyncStatus = null
|
||||
renderer = null
|
||||
appStateListeners = []
|
||||
jest.spyOn(AppState, 'addEventListener').mockImplementation((type, listener) => {
|
||||
if (type === 'change') {
|
||||
appStateListeners.push(listener as (state: AppStateStatus) => void)
|
||||
}
|
||||
return {
|
||||
remove: () => {
|
||||
appStateListeners = appStateListeners.filter((entry) => entry !== listener)
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderOffline(): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<MobilePreferencesProvider>
|
||||
<Gate />
|
||||
</MobilePreferencesProvider>,
|
||||
)
|
||||
await flushAsync()
|
||||
})
|
||||
expect(latestSyncStatus).toBe('offline')
|
||||
expect(loadingHistory[loadingHistory.length - 1]).toBe(false)
|
||||
expect(screenMounts).toBe(1)
|
||||
loadingHistory.length = 0
|
||||
}
|
||||
|
||||
test('foreground resync after an offline start never unmounts the app tree', async () => {
|
||||
await renderOffline()
|
||||
|
||||
act(() => {
|
||||
for (const listener of appStateListeners) listener('active')
|
||||
})
|
||||
await act(async () => {
|
||||
await flushAsync()
|
||||
})
|
||||
|
||||
expect(mockMaybeSingle.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(loadingHistory).not.toContain(true)
|
||||
expect(screenMounts).toBe(1)
|
||||
expect(latestSyncStatus).toBe('offline')
|
||||
})
|
||||
|
||||
test('Settings retry with no pending patch keeps the current screen mounted', async () => {
|
||||
await renderOffline()
|
||||
const retry = latestRetrySync
|
||||
if (retry === null) throw new Error('retrySync missing')
|
||||
|
||||
let pending: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
pending = retry()
|
||||
})
|
||||
await act(async () => {
|
||||
await pending
|
||||
await flushAsync()
|
||||
})
|
||||
|
||||
expect(loadingHistory).not.toContain(true)
|
||||
expect(screenMounts).toBe(1)
|
||||
})
|
||||
})
|
||||
356
apps/mobile-rn/__tests__/preferences-store.test.ts
Normal file
356
apps/mobile-rn/__tests__/preferences-store.test.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import {
|
||||
createPreferencesStore,
|
||||
preferencesOwnerFor,
|
||||
getUserCacheKey,
|
||||
INSTALLATION_CACHE_KEY,
|
||||
type PreferencesRemote,
|
||||
type PreferencesStorage,
|
||||
type PreferencesStore,
|
||||
type PreferencesStoreSnapshot,
|
||||
type RemoteInsertResult,
|
||||
type UserSettingsRowMutation,
|
||||
} from '../src/lib/preferences-store'
|
||||
|
||||
const USER_A = 'user-a'
|
||||
const USER_B = 'user-b'
|
||||
const NOW = '2026-09-28T00:00:00.000Z'
|
||||
|
||||
function createMemoryStorage(): PreferencesStorage & { data: Map<string, string>, failReads: boolean, failWrites: boolean } {
|
||||
const data = new Map<string, string>()
|
||||
const storage = {
|
||||
data,
|
||||
failReads: false,
|
||||
failWrites: false,
|
||||
async multiGet(keys: readonly string[]) {
|
||||
if (storage.failReads) throw new Error('read failed')
|
||||
return keys.map((key) => [key, data.get(key) ?? null] as const)
|
||||
},
|
||||
async setItem(key: string, value: string) {
|
||||
if (storage.failWrites) throw new Error('write failed')
|
||||
data.set(key, value)
|
||||
},
|
||||
async multiSet(pairs: Array<[string, string]>) {
|
||||
if (storage.failWrites) throw new Error('write failed')
|
||||
for (const [key, value] of pairs) data.set(key, value)
|
||||
},
|
||||
async getAllKeys() {
|
||||
return [...data.keys()]
|
||||
},
|
||||
async multiRemove(keys: readonly string[]) {
|
||||
for (const key of keys) data.delete(key)
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
function row(userId: string, overrides: Partial<UserSettingsRowMutation> = {}): UserSettingsRowMutation {
|
||||
return {
|
||||
user_id: userId,
|
||||
theme_mode: 'dark',
|
||||
locale: 'en',
|
||||
haptic_enabled: true,
|
||||
auto_polish_enabled: true,
|
||||
preferred_stt_model: null,
|
||||
preferred_llm_model: null,
|
||||
onboarding_version: 1,
|
||||
tutorial_completed_at: null,
|
||||
revision: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeRemote extends PreferencesRemote {
|
||||
rows: Map<string, UserSettingsRowMutation>
|
||||
failFetch: boolean
|
||||
fetchRow: jest.Mock<Promise<unknown>, [string]>
|
||||
insertRow: jest.Mock<Promise<RemoteInsertResult>, [UserSettingsRowMutation]>
|
||||
updateRowAtRevision: jest.Mock<Promise<unknown>, [UserSettingsRowMutation, number]>
|
||||
}
|
||||
|
||||
function createFakeRemote(): FakeRemote {
|
||||
const rows = new Map<string, UserSettingsRowMutation>()
|
||||
const remote: FakeRemote = {
|
||||
rows,
|
||||
failFetch: false,
|
||||
fetchRow: jest.fn(async (userId: string) => {
|
||||
if (remote.failFetch) throw new Error('offline')
|
||||
return rows.get(userId) ?? null
|
||||
}),
|
||||
insertRow: jest.fn(async (value: UserSettingsRowMutation): Promise<RemoteInsertResult> => {
|
||||
if (rows.has(value.user_id)) return { status: 'duplicate' }
|
||||
rows.set(value.user_id, value)
|
||||
return { status: 'inserted', row: value }
|
||||
}),
|
||||
updateRowAtRevision: jest.fn(async (value: UserSettingsRowMutation, expected: number) => {
|
||||
const current = rows.get(value.user_id)
|
||||
if (current === undefined || current.revision !== expected) return null
|
||||
rows.set(value.user_id, value)
|
||||
return value
|
||||
}),
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 30; index += 1) await Promise.resolve()
|
||||
}
|
||||
|
||||
function recordPhases(store: PreferencesStore): PreferencesStoreSnapshot[] {
|
||||
const history: PreferencesStoreSnapshot[] = []
|
||||
store.subscribe(() => history.push(store.getSnapshot()))
|
||||
return history
|
||||
}
|
||||
|
||||
describe('preferences store', () => {
|
||||
test('first load of an owner enters initial-loading, then becomes ready', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A))
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
const history = recordPhases(store)
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(history[0]?.phase).toBe('initial-loading')
|
||||
expect(store.getSnapshot()).toMatchObject({
|
||||
ownerKey: `user:${USER_A}`,
|
||||
phase: 'ready',
|
||||
syncStatus: 'synced',
|
||||
errorCode: null,
|
||||
lastSyncedAt: NOW,
|
||||
})
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
test('resync after an offline start never re-enters initial-loading', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'offline' })
|
||||
expect(store.needsForegroundResync()).toBe(true)
|
||||
|
||||
const history = recordPhases(store)
|
||||
await store.resync()
|
||||
remote.failFetch = false
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'light', revision: 4 }))
|
||||
await store.resync()
|
||||
|
||||
expect(history.length).toBeGreaterThan(0)
|
||||
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
expect(store.needsForegroundResync()).toBe(false)
|
||||
})
|
||||
|
||||
test('reloading the same ready owner is a background refresh', async () => {
|
||||
const store = createPreferencesStore({
|
||||
storage: createMemoryStorage(),
|
||||
remote: createFakeRemote(),
|
||||
now: () => NOW,
|
||||
})
|
||||
await store.load(preferencesOwnerFor(null))
|
||||
const history = recordPhases(store)
|
||||
|
||||
await store.load(preferencesOwnerFor(null))
|
||||
|
||||
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
|
||||
})
|
||||
|
||||
test('concurrent resync calls share one refresh', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.fetchRow.mockClear()
|
||||
|
||||
await Promise.all([store.resync(), store.resync(), store.resync()])
|
||||
|
||||
expect(remote.fetchRow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('resync with a pending patch flushes it instead of reloading', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 2 }))
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
remote.failFetch = true
|
||||
const result = await store.commit({ hapticEnabled: false })
|
||||
expect(result).toEqual({ localSaved: true, serverSynced: false })
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_FAILED' })
|
||||
|
||||
remote.failFetch = false
|
||||
await store.resync()
|
||||
|
||||
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false, revision: 3 })
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
|
||||
})
|
||||
|
||||
test('revision conflicts are retried against the latest row', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 5 }))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
remote.updateRowAtRevision.mockImplementationOnce(async () => {
|
||||
// Another device bumps the revision between our read and write.
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 6, locale: 'ko' }))
|
||||
return null
|
||||
})
|
||||
|
||||
const result = await store.commit({ themeMode: 'light' })
|
||||
|
||||
expect(result.serverSynced).toBe(true)
|
||||
expect(remote.rows.get(USER_A)).toMatchObject({ theme_mode: 'light', locale: 'ko', revision: 7 })
|
||||
})
|
||||
|
||||
test('repeated revision conflicts surface SYNC_CONFLICT', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.updateRowAtRevision.mockImplementation(async () => null)
|
||||
|
||||
const result = await store.commit({ themeMode: 'light' })
|
||||
|
||||
expect(result.serverSynced).toBe(false)
|
||||
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(3)
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_CONFLICT' })
|
||||
})
|
||||
|
||||
test('an insert race (unique violation) falls back to the revision-checked update', async () => {
|
||||
const remote = createFakeRemote()
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
remote.insertRow.mockImplementationOnce(async () => {
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 3 }))
|
||||
return { status: 'duplicate' }
|
||||
})
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(1)
|
||||
expect(remote.rows.get(USER_A)?.revision).toBe(4)
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
|
||||
})
|
||||
|
||||
test('a stale server response is dropped after the owner switches', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
|
||||
remote.rows.set(USER_B, row(USER_B, { theme_mode: 'light' }))
|
||||
let releaseA: () => void = () => undefined
|
||||
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
|
||||
await new Promise<void>((resolve) => { releaseA = resolve })
|
||||
return remote.rows.get(userId) ?? null
|
||||
})
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
|
||||
const loadA = store.load(preferencesOwnerFor(USER_A))
|
||||
await settle()
|
||||
await store.load(preferencesOwnerFor(USER_B))
|
||||
releaseA()
|
||||
await loadA
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ ownerKey: `user:${USER_B}`, phase: 'ready' })
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
test('realtime rows are ignored while local edits are pending', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.failFetch = true
|
||||
await store.commit({ themeMode: 'light' })
|
||||
|
||||
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'system', revision: 9 }))
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
|
||||
store.applyRemote(USER_B, row(USER_B, { theme_mode: 'system' }))
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
test('realtime rows apply when nothing is pending', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
|
||||
const storage = createMemoryStorage()
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'light', revision: 2 }))
|
||||
await settle()
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'synced', phase: 'ready' })
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
expect(storage.data.get(getUserCacheKey(USER_A))).toContain('"themeMode":"light"')
|
||||
})
|
||||
|
||||
test('cache read failure outranks the sync failure code', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.failReads = true
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_READ_FAILED' })
|
||||
})
|
||||
|
||||
test('cache write failure outranks the sync failure code', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.failWrites = true
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_WRITE_FAILED' })
|
||||
})
|
||||
|
||||
test('signed-out commits stay local and persist a pending installation patch', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(null))
|
||||
|
||||
const result = await store.commit({ locale: 'en' })
|
||||
|
||||
expect(result).toEqual({ localSaved: true, serverSynced: false })
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'local', ownerKey: 'installation' })
|
||||
expect(storage.data.get(INSTALLATION_CACHE_KEY)).toContain('"pendingPatch":{"locale":"en"}')
|
||||
expect(remote.fetchRow).not.toHaveBeenCalled()
|
||||
expect(store.needsForegroundResync()).toBe(false)
|
||||
})
|
||||
|
||||
test('a commit made during a background refresh stays pending on top of the server value', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark', revision: 2 }))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.failFetch = true
|
||||
await store.resync() // -> offline, nothing pending
|
||||
remote.failFetch = false
|
||||
|
||||
let releaseFetch: () => void = () => undefined
|
||||
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
|
||||
await new Promise<void>((resolve) => { releaseFetch = resolve })
|
||||
return remote.rows.get(userId) ?? null
|
||||
})
|
||||
const refresh = store.resync()
|
||||
await settle()
|
||||
const commit = store.commit({ hapticEnabled: false })
|
||||
await settle()
|
||||
releaseFetch()
|
||||
await refresh
|
||||
const result = await commit
|
||||
|
||||
expect(result.serverSynced).toBe(true)
|
||||
expect(store.getSnapshot().preferences.hapticEnabled).toBe(false)
|
||||
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false })
|
||||
expect(store.getSnapshot().phase).toBe('ready')
|
||||
})
|
||||
})
|
||||
314
apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts
Normal file
314
apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { FileSystem } from 'react-native-file-access'
|
||||
import { AudioPipelineError, type LocalAudioInput } from '../src/features/import/audio-import-types'
|
||||
import { transcribeAudioLocally } from '../src/features/import/local-whisper-transcription'
|
||||
import { classifySttResponse, parseSttResult } from '../src/features/import/stt-cloud-client'
|
||||
import { transcribeWithLocalFallback } from '../src/features/import/stt-engine'
|
||||
import { isRetryable, shouldFallBackToLocal } from '../src/features/import/stt-policy'
|
||||
import { processAudioInput } from '../src/features/import/audio-transcription-service'
|
||||
import { transcribeTalkRecording } from '../src/features/talk/talk-transcription-service'
|
||||
import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input'
|
||||
import {
|
||||
beginMeetingProcessing,
|
||||
completeMeetingProcessing,
|
||||
} from '../src/features/meetings/meetings-service'
|
||||
|
||||
jest.mock('../src/features/import/local-whisper-transcription', () => ({
|
||||
transcribeAudioLocally: jest.fn(),
|
||||
}))
|
||||
jest.mock('../src/features/import/recorded-audio-input', () => ({
|
||||
prepareRecordedAudio: jest.fn(),
|
||||
}))
|
||||
jest.mock('../src/features/import/resumable-audio-upload', () => ({
|
||||
uploadAudioResumably: jest.fn(),
|
||||
}))
|
||||
jest.mock('../src/features/meetings/meetings-service', () => ({
|
||||
beginMeetingProcessing: jest.fn(),
|
||||
completeMeetingProcessing: jest.fn(),
|
||||
}))
|
||||
jest.mock('../src/lib/native-config', () => ({
|
||||
getMobileRuntimeConfig: () => ({ appVersion: '1.9.0' }),
|
||||
}))
|
||||
|
||||
interface RecordedQuery {
|
||||
table: string
|
||||
calls: Array<[string, unknown[]]>
|
||||
}
|
||||
type QueryResult = { data: unknown; error: unknown }
|
||||
|
||||
const mockQueries: RecordedQuery[] = []
|
||||
let mockResolveQuery: (query: RecordedQuery) => QueryResult = () => ({ data: null, error: null })
|
||||
|
||||
function mockBuilder(table: string): Record<string, unknown> {
|
||||
const recorded: RecordedQuery = { table, calls: [] }
|
||||
mockQueries.push(recorded)
|
||||
const builder: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'insert', 'update', 'delete', 'eq', 'neq', 'order', 'limit']) {
|
||||
builder[method] = (...args: unknown[]) => {
|
||||
recorded.calls.push([method, args])
|
||||
return builder
|
||||
}
|
||||
}
|
||||
for (const terminal of ['maybeSingle', 'single']) {
|
||||
builder[terminal] = async () => {
|
||||
recorded.calls.push([terminal, []])
|
||||
return mockResolveQuery(recorded)
|
||||
}
|
||||
}
|
||||
builder.then = (
|
||||
onFulfilled: (value: QueryResult) => unknown,
|
||||
onRejected?: (reason: unknown) => unknown,
|
||||
) => Promise.resolve(mockResolveQuery(recorded)).then(onFulfilled, onRejected)
|
||||
return builder
|
||||
}
|
||||
|
||||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: jest.fn(async () => ({
|
||||
data: { session: { user: { id: '00000000-0000-4000-8000-000000000001' }, access_token: 'user-token' } },
|
||||
error: null,
|
||||
})),
|
||||
},
|
||||
from: jest.fn((table: string) => mockBuilder(table)),
|
||||
},
|
||||
}))
|
||||
|
||||
const originalFetch = global.fetch
|
||||
const SHA = 'a'.repeat(64)
|
||||
const USER_ID = '00000000-0000-4000-8000-000000000001'
|
||||
|
||||
function input(): LocalAudioInput {
|
||||
return {
|
||||
uri: 'file:///cache/meeting.m4a',
|
||||
path: '/cache/meeting.m4a',
|
||||
fileName: 'meeting.m4a',
|
||||
mimeType: 'audio/mp4',
|
||||
sizeBytes: 3,
|
||||
durationMs: 1_500,
|
||||
source: 'recording',
|
||||
dispose: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
function uploadedAudio(meetingId: string | null): Record<string, unknown> {
|
||||
return {
|
||||
id: 'audio-1',
|
||||
user_id: USER_ID,
|
||||
storage_key: `${USER_ID}/imports/${SHA}/meeting.m4a`,
|
||||
upload_status: 'uploaded',
|
||||
history_id: null,
|
||||
meeting_id: meetingId,
|
||||
}
|
||||
}
|
||||
|
||||
function historyWrites(): RecordedQuery[] {
|
||||
return mockQueries.filter(query =>
|
||||
query.table === 'history'
|
||||
&& query.calls.some(([method]) => method === 'insert' || method === 'update'))
|
||||
}
|
||||
|
||||
function writtenPayload(query: RecordedQuery): Record<string, unknown> {
|
||||
const call = query.calls.find(([method]) => method === 'insert' || method === 'update')
|
||||
return (call?.[1][0] ?? {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockQueries.length = 0
|
||||
;(FileSystem.hash as jest.Mock).mockResolvedValue(SHA)
|
||||
;(FileSystem.readFile as jest.Mock).mockResolvedValue('AQID')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('stt-proxy status classification (single contract table)', () => {
|
||||
it.each([
|
||||
[200, null],
|
||||
[400, 'transcription'],
|
||||
[401, 'auth'],
|
||||
[403, 'auth'],
|
||||
[413, 'file-too-large'],
|
||||
[415, 'transcription'],
|
||||
[422, 'no-speech'],
|
||||
[429, 'quota'],
|
||||
[500, 'transcription'],
|
||||
[502, 'provider-unavailable'],
|
||||
[503, 'provider-unavailable'],
|
||||
[504, 'provider-unavailable'],
|
||||
])('HTTP %i -> %s', (status, code) => {
|
||||
const error = classifySttResponse(status, '{"error":"x"}')
|
||||
expect(error === null ? null : error.code).toBe(code)
|
||||
})
|
||||
|
||||
it('keeps the server error code in the message for diagnostics', () => {
|
||||
expect(classifySttResponse(422, '{"error":"stt_no_speech","attempts":[]}')?.message)
|
||||
.toContain('stt_no_speech')
|
||||
})
|
||||
|
||||
it('accepts a result without confidence and rejects an empty transcript', () => {
|
||||
expect(parseSttResult({
|
||||
transcript: ' hi ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram',
|
||||
}, 5)).toMatchObject({ text: 'hi', confidence: null })
|
||||
expect(() => parseSttResult({
|
||||
transcript: ' ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', confidence: 1,
|
||||
}, 5)).toThrow(expect.objectContaining({ code: 'transcription' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('stt policy', () => {
|
||||
it('only lets provider/transport failures fall back to on-device whisper', () => {
|
||||
expect(shouldFallBackToLocal('provider-unavailable')).toBe(true)
|
||||
expect(shouldFallBackToLocal('transcription')).toBe(true)
|
||||
for (const code of ['no-speech', 'auth', 'quota', 'file-too-large', 'cancelled', 'file-read'] as const) {
|
||||
expect(shouldFallBackToLocal(code)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps retry separate from fallback: auth retries but never falls back, no-speech does neither', () => {
|
||||
expect(isRetryable('auth')).toBe(true)
|
||||
expect(shouldFallBackToLocal('auth')).toBe(false)
|
||||
expect(isRetryable('no-speech')).toBe(false)
|
||||
expect(isRetryable('quota')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcribeWithLocalFallback', () => {
|
||||
const signal = new AbortController().signal
|
||||
|
||||
it('does not run the local engine when the cloud reports no speech', async () => {
|
||||
const local = jest.fn()
|
||||
await expect(transcribeWithLocalFallback({
|
||||
input: input(),
|
||||
languageCode: 'ko',
|
||||
signal,
|
||||
cloud: async () => { throw new AudioPipelineError('no-speech', 'silent') },
|
||||
local,
|
||||
})).rejects.toMatchObject({ code: 'no-speech' })
|
||||
expect(local).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('turns an empty local transcript into no-speech instead of a result', async () => {
|
||||
const onFallback = jest.fn()
|
||||
await expect(transcribeWithLocalFallback({
|
||||
input: input(),
|
||||
languageCode: 'ko',
|
||||
signal,
|
||||
cloud: async () => { throw new AudioPipelineError('provider-unavailable', 'down') },
|
||||
local: async () => ({
|
||||
text: '', confidence: null, language: 'ko', durationSeconds: 1, provider: 'local', latencyMs: 1,
|
||||
}),
|
||||
onFallback,
|
||||
})).rejects.toMatchObject({ code: 'no-speech' })
|
||||
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ code: 'provider-unavailable' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('file/meeting pipeline with stt-proxy 422 stt_no_speech', () => {
|
||||
function respondNoSpeech(): jest.Mock {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => JSON.stringify({ error: 'stt_no_speech', attempts: [] }),
|
||||
})
|
||||
global.fetch = fetchMock
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
it('fails a history transcription as no-speech without local fallback and records the failure', async () => {
|
||||
mockResolveQuery = query => {
|
||||
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
|
||||
return { data: uploadedAudio(null), error: null }
|
||||
}
|
||||
if (query.table === 'history') return { data: { id: 'history-1' }, error: null }
|
||||
return { data: null, error: null }
|
||||
}
|
||||
const fetchMock = respondNoSpeech()
|
||||
|
||||
await expect(processAudioInput(input(), {
|
||||
expectedUserId: USER_ID,
|
||||
languageCode: 'ko',
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toMatchObject({ code: 'no-speech' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(transcribeAudioLocally).not.toHaveBeenCalled()
|
||||
const writes = historyWrites()
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' })
|
||||
})
|
||||
|
||||
it('fails a meeting transcription as no-speech without completing it or running whisper', async () => {
|
||||
mockResolveQuery = query => {
|
||||
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
|
||||
return { data: uploadedAudio('meeting-1'), error: null }
|
||||
}
|
||||
return { data: null, error: null }
|
||||
}
|
||||
;(beginMeetingProcessing as jest.Mock).mockResolvedValue({ id: 'job-1' })
|
||||
respondNoSpeech()
|
||||
|
||||
await expect(processAudioInput(input(), {
|
||||
expectedUserId: USER_ID,
|
||||
languageCode: 'ko',
|
||||
signal: new AbortController().signal,
|
||||
meetingId: 'meeting-1',
|
||||
})).rejects.toMatchObject({ code: 'no-speech' })
|
||||
|
||||
expect(transcribeAudioLocally).not.toHaveBeenCalled()
|
||||
expect(completeMeetingProcessing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not save an empty on-device transcript as a completed history', async () => {
|
||||
mockResolveQuery = query => {
|
||||
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
|
||||
return { data: uploadedAudio(null), error: null }
|
||||
}
|
||||
if (query.table === 'history') return { data: { id: 'history-1' }, error: null }
|
||||
return { data: null, error: null }
|
||||
}
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '' })
|
||||
;(transcribeAudioLocally as jest.Mock).mockResolvedValue({
|
||||
text: '', confidence: null, language: 'ko', durationSeconds: 1.5, provider: 'whisper.cpp-tiny-local', latencyMs: 10,
|
||||
})
|
||||
|
||||
await expect(processAudioInput(input(), {
|
||||
expectedUserId: USER_ID,
|
||||
languageCode: 'ko',
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toMatchObject({ code: 'no-speech' })
|
||||
|
||||
const writes = historyWrites()
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Talk with stt-proxy 422 stt_no_speech', () => {
|
||||
it('reports no-speech without running on-device whisper', async () => {
|
||||
const recorded = input()
|
||||
;(prepareRecordedAudio as jest.Mock).mockResolvedValue(recorded)
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => JSON.stringify({ error: 'stt_no_speech' }),
|
||||
})
|
||||
|
||||
await expect(transcribeTalkRecording({
|
||||
uri: recorded.uri,
|
||||
path: recorded.path,
|
||||
fileName: recorded.fileName,
|
||||
mimeType: recorded.mimeType,
|
||||
size: recorded.sizeBytes,
|
||||
durationMs: 1_500,
|
||||
}, {
|
||||
accessToken: 'user-token',
|
||||
languageCode: 'ko',
|
||||
signal: new AbortController().signal,
|
||||
disposeRecording: jest.fn(),
|
||||
})).rejects.toMatchObject({ code: 'no-speech' })
|
||||
expect(transcribeAudioLocally).not.toHaveBeenCalled()
|
||||
expect(recorded.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
122
apps/mobile-rn/src/features/account/account-deletion-service.ts
Normal file
122
apps/mobile-rn/src/features/account/account-deletion-service.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// src/features/account/account-deletion-service.ts — account deletion use case.
|
||||
//
|
||||
// The screen only renders; the server contract (account-delete Edge Function),
|
||||
// the failure mapping and the side-effect ordering live here so they can be
|
||||
// tested without rendering UI.
|
||||
import {
|
||||
edgeFailureField,
|
||||
invokeEdgeFunction,
|
||||
type EdgeFunctionFailure,
|
||||
type EdgeFunctionInvoker,
|
||||
} from '../../lib/edge-functions'
|
||||
|
||||
export type AccountDeletionFailureCode =
|
||||
| 'SERVER_ENDPOINT_UNAVAILABLE'
|
||||
| 'REAUTHENTICATION_REQUIRED'
|
||||
| 'ACTIVE_SUBSCRIPTION'
|
||||
| 'REQUEST_FAILED'
|
||||
|
||||
export type AccountDeletionResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: AccountDeletionFailureCode; message?: string }
|
||||
|
||||
/** Server-owned boundary for deleting auth.users and its cascaded account data. */
|
||||
export interface AccountDeletionService {
|
||||
deleteCurrentAccount(): Promise<AccountDeletionResult>
|
||||
}
|
||||
|
||||
export const ACCOUNT_DELETE_FUNCTION = 'account-delete'
|
||||
export const ACCOUNT_DELETE_CONFIRMATION = 'DELETE_MY_ACCOUNT'
|
||||
|
||||
/**
|
||||
* account-delete puts the machine code in `code` and an English diagnostic in
|
||||
* `error`. The diagnostic is kept for logs only; UI copy comes from i18n.
|
||||
*/
|
||||
export function mapAccountDeletionFailure(failure: EdgeFunctionFailure): AccountDeletionResult {
|
||||
const serverCode = edgeFailureField(failure, 'code')
|
||||
const message = edgeFailureField(failure, 'error') ?? failure.message ?? undefined
|
||||
if (serverCode === 'REAUTHENTICATION_REQUIRED') {
|
||||
return { ok: false, code: 'REAUTHENTICATION_REQUIRED', message }
|
||||
}
|
||||
if (serverCode === 'ACTIVE_SUBSCRIPTION') {
|
||||
return { ok: false, code: 'ACTIVE_SUBSCRIPTION', message }
|
||||
}
|
||||
if (failure.status === 404) {
|
||||
return { ok: false, code: 'SERVER_ENDPOINT_UNAVAILABLE', message }
|
||||
}
|
||||
return { ok: false, code: 'REQUEST_FAILED', message }
|
||||
}
|
||||
|
||||
export function createEdgeAccountDeletionService(
|
||||
invoke: EdgeFunctionInvoker = invokeEdgeFunction,
|
||||
): AccountDeletionService {
|
||||
return Object.freeze({
|
||||
async deleteCurrentAccount(): Promise<AccountDeletionResult> {
|
||||
const result = await invoke<{ success?: unknown; error?: unknown }>(
|
||||
ACCOUNT_DELETE_FUNCTION,
|
||||
{ confirmation: ACCOUNT_DELETE_CONFIRMATION },
|
||||
)
|
||||
if (!result.ok) return mapAccountDeletionFailure(result)
|
||||
if (result.data?.success !== true) {
|
||||
const message = typeof result.data?.error === 'string' ? result.data.error : undefined
|
||||
return { ok: false, code: 'REQUEST_FAILED', message }
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const edgeAccountDeletionService: AccountDeletionService = createEdgeAccountDeletionService()
|
||||
|
||||
export type AccountDeletionFailureMessageKey =
|
||||
| 'mobile.account.deleteUnavailable'
|
||||
| 'mobile.account.reauthenticationRequired'
|
||||
| 'mobile.account.deleteFailed'
|
||||
|
||||
/** i18n key for a deletion failure. Raw server/SDK text is never shown. */
|
||||
export function accountDeletionFailureMessageKey(
|
||||
code: AccountDeletionFailureCode,
|
||||
): AccountDeletionFailureMessageKey {
|
||||
switch (code) {
|
||||
case 'SERVER_ENDPOINT_UNAVAILABLE':
|
||||
return 'mobile.account.deleteUnavailable'
|
||||
case 'REAUTHENTICATION_REQUIRED':
|
||||
return 'mobile.account.reauthenticationRequired'
|
||||
default:
|
||||
return 'mobile.account.deleteFailed'
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccountDeletionWorkflow {
|
||||
deletionService: AccountDeletionService
|
||||
/**
|
||||
* Detaches this device's push registration. Runs only after the server has
|
||||
* confirmed deletion, so a refused deletion (re-auth, active subscription)
|
||||
* leaves the still-live account's notifications intact.
|
||||
*/
|
||||
detachPushRegistration: () => Promise<void>
|
||||
/** Removes every account-owned local artifact and the secure session. */
|
||||
purgeLocalSession: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current account. Order: server deletion → push detach
|
||||
* (best-effort, needs the session) → local purge. A failed deletion performs
|
||||
* no side effect. A purge failure propagates to the caller.
|
||||
*/
|
||||
export async function runAccountDeletion(
|
||||
workflow: AccountDeletionWorkflow,
|
||||
): Promise<AccountDeletionResult> {
|
||||
const result = await workflow.deletionService.deleteCurrentAccount()
|
||||
if (!result.ok) return result
|
||||
// The account no longer exists, so a push-detach failure must not block the
|
||||
// local privacy purge; detachPushRegistrationForLogout already tolerates a
|
||||
// single-boundary failure and only throws when both boundaries fail.
|
||||
try {
|
||||
await workflow.detachPushRegistration()
|
||||
} catch {
|
||||
// Best-effort by design; see above.
|
||||
}
|
||||
await workflow.purgeLocalSession()
|
||||
return result
|
||||
}
|
||||
|
|
@ -21,16 +21,20 @@ import {
|
|||
countTranscriptWords,
|
||||
decodeBase64,
|
||||
} from './audio-validation';
|
||||
import { createMultipartAudioBody } from './multipart-audio';
|
||||
import { createLinkedDeadline } from './linked-deadline';
|
||||
import { transcribeAudioLocally } from './local-whisper-transcription';
|
||||
import { uploadAudioResumably } from './resumable-audio-upload';
|
||||
import {
|
||||
beginMeetingProcessing,
|
||||
completeMeetingProcessing,
|
||||
} from '../meetings/meetings-service';
|
||||
import { transcribeCloud } from './stt-cloud-client';
|
||||
import { transcribeWithLocalFallback } from './stt-engine';
|
||||
import { createLogger } from '../../lib/logger';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
const TRANSCRIPTION_TIMEOUT_MS = 300_000;
|
||||
const log = createLogger('AudioPipeline');
|
||||
|
||||
interface OperationState {
|
||||
audioFile: AudioFile | null;
|
||||
|
|
@ -41,12 +45,6 @@ interface OperationState {
|
|||
processingJobId: string | null;
|
||||
}
|
||||
|
||||
interface LinkedDeadline {
|
||||
signal: AbortSignal;
|
||||
timedOut: () => boolean;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function database(): D3roSupabaseClient {
|
||||
return supabase as unknown as D3roSupabaseClient;
|
||||
}
|
||||
|
|
@ -78,31 +76,6 @@ async function requireCurrentSession(
|
|||
return session;
|
||||
}
|
||||
|
||||
function createLinkedDeadline(
|
||||
parentSignal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
): LinkedDeadline {
|
||||
const controller = new AbortController();
|
||||
let didTimeOut = false;
|
||||
const abortFromParent = (): void => controller.abort();
|
||||
parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
||||
const timeout = setTimeout(() => {
|
||||
didTimeOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
timedOut: () => didTimeOut,
|
||||
dispose: () => {
|
||||
clearTimeout(timeout);
|
||||
parentSignal.removeEventListener('abort', abortFromParent);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodedStoragePath(storageKey: string): string {
|
||||
return storageKey.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
|
@ -259,122 +232,6 @@ async function uploadAudio(
|
|||
}
|
||||
}
|
||||
|
||||
function parseTranscriptionResponse(
|
||||
value: unknown,
|
||||
latencyMs: number,
|
||||
): MobileTranscriptionResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
'The transcription service returned an invalid response',
|
||||
);
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.transcript !== 'string' ||
|
||||
typeof candidate.confidence !== 'number' ||
|
||||
!Number.isFinite(candidate.confidence) ||
|
||||
typeof candidate.language_code !== 'string' ||
|
||||
candidate.language_code.trim() === '' ||
|
||||
typeof candidate.duration_seconds !== 'number' ||
|
||||
!Number.isFinite(candidate.duration_seconds) ||
|
||||
candidate.duration_seconds < 0 ||
|
||||
typeof candidate.provider !== 'string' ||
|
||||
candidate.provider.trim() === ''
|
||||
) {
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
'The transcription service returned incomplete audio results',
|
||||
);
|
||||
}
|
||||
return {
|
||||
text: candidate.transcript,
|
||||
confidence: Math.max(0, Math.min(1, candidate.confidence)),
|
||||
language: candidate.language_code,
|
||||
durationSeconds: candidate.duration_seconds,
|
||||
provider: candidate.provider,
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
async function transcribeAudioFile(
|
||||
input: LocalAudioInput,
|
||||
audioBytes: Uint8Array,
|
||||
session: Session,
|
||||
languageCode: string,
|
||||
parentSignal: AbortSignal,
|
||||
): Promise<MobileTranscriptionResult> {
|
||||
const multipart = createMultipartAudioBody(input, audioBytes, languageCode);
|
||||
|
||||
const deadline = createLinkedDeadline(parentSignal, TRANSCRIPTION_TIMEOUT_MS);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${SUPABASE_URL}/functions/v1/stt-proxy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`,
|
||||
},
|
||||
body: multipart.body,
|
||||
signal: deadline.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AudioPipelineError(
|
||||
'auth',
|
||||
'The transcription session is no longer authorized',
|
||||
);
|
||||
}
|
||||
if (response.status === 413) {
|
||||
throw new AudioPipelineError(
|
||||
'file-too-large',
|
||||
'The transcription service rejected the file size',
|
||||
);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new AudioPipelineError(
|
||||
'quota',
|
||||
'Your transcription quota has been reached',
|
||||
);
|
||||
}
|
||||
if (response.status === 503) {
|
||||
throw new AudioPipelineError(
|
||||
'provider-unavailable',
|
||||
'No transcription provider is currently available',
|
||||
);
|
||||
}
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
`Transcription failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
return parseTranscriptionResponse(body, Date.now() - startedAt);
|
||||
} catch (error) {
|
||||
if (error instanceof AudioPipelineError) throw error;
|
||||
if (parentSignal.aborted) {
|
||||
throw new AudioPipelineError('cancelled', 'Transcription was cancelled');
|
||||
}
|
||||
if (deadline.timedOut()) {
|
||||
throw new AudioPipelineError(
|
||||
'provider-unavailable',
|
||||
'The transcription service timed out',
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
'The transcription service could not be reached',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
deadline.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function historyTitle(fileName: string): string {
|
||||
const withoutExtension = fileName.replace(/\.[^.]+$/, '').trim();
|
||||
return (withoutExtension === '' ? 'Audio transcription' : withoutExtension).slice(0, 160);
|
||||
|
|
@ -741,52 +598,28 @@ async function processCore(
|
|||
options.signal,
|
||||
);
|
||||
state.lastAccessToken = transcriptionSession.access_token;
|
||||
let transcription: MobileTranscriptionResult;
|
||||
try {
|
||||
transcription = await transcribeAudioFile(
|
||||
const transcription: MobileTranscriptionResult = await transcribeWithLocalFallback({
|
||||
input,
|
||||
languageCode: options.languageCode,
|
||||
signal: options.signal,
|
||||
cloud: () => transcribeCloud({
|
||||
input,
|
||||
audioBytes,
|
||||
transcriptionSession,
|
||||
options.languageCode,
|
||||
options.signal,
|
||||
);
|
||||
} catch (cloudError) {
|
||||
if (
|
||||
!(cloudError instanceof AudioPipelineError) ||
|
||||
(cloudError.code !== 'provider-unavailable' &&
|
||||
cloudError.code !== 'transcription')
|
||||
) {
|
||||
throw cloudError;
|
||||
}
|
||||
|
||||
audioBytes = new Uint8Array(0);
|
||||
try {
|
||||
transcription = await transcribeAudioLocally(
|
||||
input,
|
||||
options.languageCode,
|
||||
options.signal,
|
||||
bytes: audioBytes,
|
||||
accessToken: transcriptionSession.access_token,
|
||||
languageCode: options.languageCode,
|
||||
signal: options.signal,
|
||||
timeoutMs: TRANSCRIPTION_TIMEOUT_MS,
|
||||
}),
|
||||
local: transcribeAudioLocally,
|
||||
onFallback: cloudError => {
|
||||
// Release the uploaded bytes before on-device Whisper decodes the file.
|
||||
audioBytes = new Uint8Array(0);
|
||||
log.warn(
|
||||
'cloud STT failed, falling back to on-device whisper:',
|
||||
`${cloudError.code}: ${cloudError.message}`,
|
||||
);
|
||||
} catch (localError) {
|
||||
if (
|
||||
localError instanceof AudioPipelineError &&
|
||||
localError.code === 'cancelled'
|
||||
) {
|
||||
throw localError;
|
||||
}
|
||||
if (localError instanceof AudioPipelineError) {
|
||||
throw new AudioPipelineError(
|
||||
localError.code,
|
||||
localError.message,
|
||||
{ cloudError, localError },
|
||||
);
|
||||
}
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
'Cloud and on-device transcription both failed',
|
||||
{ cloudError, localError },
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
options.onPhase?.('saving');
|
||||
await requireCurrentSession(options.expectedUserId, options.signal);
|
||||
|
|
|
|||
35
apps/mobile-rn/src/features/import/linked-deadline.ts
Normal file
35
apps/mobile-rn/src/features/import/linked-deadline.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* An AbortSignal that fires when either the parent signal aborts or the
|
||||
* deadline elapses. `timedOut()` tells the two apart so callers can map a
|
||||
* deadline to a retryable provider error and a parent abort to cancellation.
|
||||
*/
|
||||
export interface LinkedDeadline {
|
||||
signal: AbortSignal;
|
||||
timedOut: () => boolean;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createLinkedDeadline(
|
||||
parentSignal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
): LinkedDeadline {
|
||||
const controller = new AbortController();
|
||||
let didTimeOut = false;
|
||||
const abortFromParent = (): void => controller.abort();
|
||||
parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
||||
const timeout = setTimeout(() => {
|
||||
didTimeOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
timedOut: () => didTimeOut,
|
||||
dispose: () => {
|
||||
clearTimeout(timeout);
|
||||
parentSignal.removeEventListener('abort', abortFromParent);
|
||||
},
|
||||
};
|
||||
}
|
||||
170
apps/mobile-rn/src/features/import/stt-cloud-client.ts
Normal file
170
apps/mobile-rn/src/features/import/stt-cloud-client.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config';
|
||||
import {
|
||||
AudioPipelineError,
|
||||
type LocalAudioInput,
|
||||
type MobileTranscriptionResult,
|
||||
} from './audio-import-types';
|
||||
import { createLinkedDeadline } from './linked-deadline';
|
||||
import { createMultipartAudioBody } from './multipart-audio';
|
||||
|
||||
/**
|
||||
* The one mobile client for the `stt-proxy` edge function. Talk and the
|
||||
* file/meeting pipeline both go through here so the HTTP contract (status
|
||||
* table, response shape, timeout/cancel mapping) is defined exactly once.
|
||||
*/
|
||||
|
||||
export const STT_PROXY_ENDPOINT = `${SUPABASE_URL}/functions/v1/stt-proxy`;
|
||||
|
||||
const MAX_BODY_SNIPPET = 160;
|
||||
|
||||
export interface CloudSttRequest {
|
||||
input: LocalAudioInput;
|
||||
bytes: Uint8Array;
|
||||
accessToken: string;
|
||||
languageCode: string;
|
||||
signal: AbortSignal;
|
||||
/** Talk waits 120 s, the file/meeting pipeline 300 s. */
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
function bodySnippet(bodyText: string): string {
|
||||
return bodyText.replace(/\s+/g, ' ').trim().slice(0, MAX_BODY_SNIPPET);
|
||||
}
|
||||
|
||||
function describe(status: number, bodyText: string): string {
|
||||
const snippet = bodySnippet(bodyText);
|
||||
return snippet.length === 0 ? `HTTP ${status}` : `HTTP ${status}: ${snippet}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an stt-proxy HTTP status (and its body, for diagnostics) to the
|
||||
* pipeline error it stands for. Returns null for a 2xx answer.
|
||||
*
|
||||
* stt-proxy contract (server/supabase/functions/stt-proxy/handler.ts):
|
||||
* - 401/403 auth rejected -> 'auth'
|
||||
* - 413 audio_too_large -> 'file-too-large'
|
||||
* - 422 stt_no_speech (quota already billed) -> 'no-speech'
|
||||
* - 429 quota_exceeded -> 'quota'
|
||||
* - 502 stt_upstream_failed / 503 stt_provider_unavailable / 504 gateway
|
||||
* -> 'provider-unavailable'
|
||||
* - anything else (400, 415, 500, ...) -> 'transcription'
|
||||
*/
|
||||
export function classifySttResponse(
|
||||
status: number,
|
||||
bodyText = '',
|
||||
): AudioPipelineError | null {
|
||||
if (status >= 200 && status < 300) return null;
|
||||
const detail = describe(status, bodyText);
|
||||
switch (status) {
|
||||
case 401:
|
||||
case 403:
|
||||
return new AudioPipelineError('auth', `The speech session is no longer authorized (${detail})`);
|
||||
case 413:
|
||||
return new AudioPipelineError('file-too-large', `The speech service rejected the file size (${detail})`);
|
||||
case 422:
|
||||
return new AudioPipelineError('no-speech', `No speech was detected in the recording (${detail})`);
|
||||
case 429:
|
||||
return new AudioPipelineError('quota', `The speech transcription quota is exhausted (${detail})`);
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new AudioPipelineError('provider-unavailable', `Cloud STT unavailable (${detail})`);
|
||||
default:
|
||||
return new AudioPipelineError('transcription', `Speech transcription failed (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a 2xx stt-proxy body. `confidence` is optional (not every provider
|
||||
* reports one) and an empty transcript is rejected: stt-proxy reports silence
|
||||
* as 422, so an empty 2xx transcript is an unusable response, not a result.
|
||||
*/
|
||||
export function parseSttResult(value: unknown, latencyMs: number): MobileTranscriptionResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new AudioPipelineError('transcription', 'The speech service returned invalid data');
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
if (
|
||||
!nonEmptyString(body.transcript)
|
||||
|| !nonEmptyString(body.language_code)
|
||||
|| typeof body.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(body.duration_seconds)
|
||||
|| body.duration_seconds < 0
|
||||
|| !nonEmptyString(body.provider)
|
||||
) {
|
||||
throw new AudioPipelineError('transcription', 'The speech service returned incomplete data');
|
||||
}
|
||||
|
||||
const confidence = typeof body.confidence === 'number' && Number.isFinite(body.confidence)
|
||||
? Math.max(0, Math.min(1, body.confidence))
|
||||
: null;
|
||||
return {
|
||||
text: body.transcript.trim(),
|
||||
confidence,
|
||||
language: body.language_code.trim(),
|
||||
durationSeconds: body.duration_seconds,
|
||||
provider: body.provider.trim(),
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rejects a missing bearer token before any audio leaves the device. */
|
||||
export function requireSttAccessToken(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
if (token.length === 0) {
|
||||
throw new AudioPipelineError('auth', 'Sign in to transcribe speech');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function readBodyText(response: Response): Promise<string> {
|
||||
try {
|
||||
return typeof response.text === 'function' ? await response.text() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcribeCloud(request: CloudSttRequest): Promise<MobileTranscriptionResult> {
|
||||
const token = requireSttAccessToken(request.accessToken);
|
||||
if (request.signal.aborted) {
|
||||
throw new AudioPipelineError('cancelled', 'Speech transcription was cancelled');
|
||||
}
|
||||
const multipart = createMultipartAudioBody(request.input, request.bytes, request.languageCode);
|
||||
const deadline = createLinkedDeadline(request.signal, request.timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(STT_PROXY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`,
|
||||
},
|
||||
body: multipart.body,
|
||||
signal: deadline.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const failure = classifySttResponse(response.status, await readBodyText(response))
|
||||
?? new AudioPipelineError('transcription', `Speech transcription failed (HTTP ${response.status})`);
|
||||
throw failure;
|
||||
}
|
||||
return parseSttResult(await response.json(), Date.now() - startedAt);
|
||||
} catch (error) {
|
||||
if (error instanceof AudioPipelineError) throw error;
|
||||
if (request.signal.aborted) {
|
||||
throw new AudioPipelineError('cancelled', 'Speech transcription was cancelled', error);
|
||||
}
|
||||
if (deadline.timedOut()) {
|
||||
throw new AudioPipelineError('provider-unavailable', 'Cloud speech recognition timed out', error);
|
||||
}
|
||||
throw new AudioPipelineError('transcription', 'Cloud speech recognition could not be reached', error);
|
||||
} finally {
|
||||
deadline.dispose();
|
||||
}
|
||||
}
|
||||
74
apps/mobile-rn/src/features/import/stt-engine.ts
Normal file
74
apps/mobile-rn/src/features/import/stt-engine.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
AudioPipelineError,
|
||||
type LocalAudioInput,
|
||||
type MobileTranscriptionResult,
|
||||
} from './audio-import-types';
|
||||
import { shouldFallBackToLocal } from './stt-policy';
|
||||
|
||||
/**
|
||||
* Port for anything that turns a local audio file into a transcript. The
|
||||
* on-device Whisper runner (`transcribeAudioLocally`) satisfies it directly.
|
||||
*/
|
||||
export type SttEngine = (
|
||||
input: LocalAudioInput,
|
||||
languageCode: string,
|
||||
signal: AbortSignal,
|
||||
) => Promise<MobileTranscriptionResult>;
|
||||
|
||||
export interface FallbackTranscriptionRequest {
|
||||
input: LocalAudioInput;
|
||||
languageCode: string;
|
||||
signal: AbortSignal;
|
||||
/** The primary (cloud) attempt, already bound to its credentials and bytes. */
|
||||
cloud: () => Promise<MobileTranscriptionResult>;
|
||||
/** The secondary engine, used only when stt-policy allows it. */
|
||||
local: SttEngine;
|
||||
/** Observes the cloud failure right before the local engine runs. */
|
||||
onFallback?: (cloudError: AudioPipelineError) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "cloud first, on-device second" orchestration for Talk and the
|
||||
* file/meeting pipeline:
|
||||
*
|
||||
* 1. Any cloud error that stt-policy does not mark as fallback-eligible is
|
||||
* rethrown untouched (auth, quota, no-speech, cancelled, ...).
|
||||
* 2. A local failure keeps its own code and message and carries both errors
|
||||
* as its cause; a local cancellation is rethrown as-is.
|
||||
* 3. An empty local transcript is 'no-speech', never a completed result.
|
||||
*/
|
||||
export async function transcribeWithLocalFallback(
|
||||
request: FallbackTranscriptionRequest,
|
||||
): Promise<MobileTranscriptionResult> {
|
||||
let cloudError: AudioPipelineError;
|
||||
try {
|
||||
return await request.cloud();
|
||||
} catch (error) {
|
||||
if (!(error instanceof AudioPipelineError) || !shouldFallBackToLocal(error.code)) {
|
||||
throw error;
|
||||
}
|
||||
cloudError = error;
|
||||
}
|
||||
|
||||
request.onFallback?.(cloudError);
|
||||
|
||||
let result: MobileTranscriptionResult;
|
||||
try {
|
||||
result = await request.local(request.input, request.languageCode, request.signal);
|
||||
} catch (localError) {
|
||||
if (localError instanceof AudioPipelineError) {
|
||||
if (localError.code === 'cancelled') throw localError;
|
||||
throw new AudioPipelineError(localError.code, localError.message, { cloudError, localError });
|
||||
}
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
'Cloud and on-device speech recognition both failed',
|
||||
{ cloudError, localError },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.text.trim().length === 0) {
|
||||
throw new AudioPipelineError('no-speech', 'No speech was detected in the recording', cloudError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
45
apps/mobile-rn/src/features/import/stt-policy.ts
Normal file
45
apps/mobile-rn/src/features/import/stt-policy.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { AudioPipelineErrorCode } from './audio-import-types';
|
||||
|
||||
/**
|
||||
* Single source of truth for how the mobile STT pipelines react to an
|
||||
* AudioPipelineError code. Pure and dependency-free so the durable queue, the
|
||||
* file/meeting pipeline and Talk can all share it without pulling in IO.
|
||||
*
|
||||
* The two predicates are deliberately independent: an expired session
|
||||
* ('auth') is worth retrying later from the queue, but must never be
|
||||
* "rescued" by running on-device Whisper, which would bypass authentication
|
||||
* and the server quota.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cloud failures that may be answered by on-device Whisper instead.
|
||||
*
|
||||
* - 'provider-unavailable': no provider configured, upstream outage, timeout.
|
||||
* - 'transcription': unreachable service or an unusable response.
|
||||
*
|
||||
* Everything else is authoritative and must surface as-is. In particular
|
||||
* 'no-speech' (stt-proxy 422 stt_no_speech) is a real, already billed answer:
|
||||
* re-running a local model on silent audio only produces an empty or
|
||||
* hallucinated transcript.
|
||||
*/
|
||||
const LOCAL_FALLBACK_CODES: ReadonlySet<AudioPipelineErrorCode> = new Set<AudioPipelineErrorCode>([
|
||||
'provider-unavailable',
|
||||
'transcription',
|
||||
]);
|
||||
|
||||
/** Failures a durable queue item may be attempted again for. */
|
||||
const RETRYABLE_CODES: ReadonlySet<AudioPipelineErrorCode> = new Set<AudioPipelineErrorCode>([
|
||||
'upload',
|
||||
'provider-unavailable',
|
||||
'transcription',
|
||||
'persist',
|
||||
'auth',
|
||||
]);
|
||||
|
||||
export function shouldFallBackToLocal(code: AudioPipelineErrorCode): boolean {
|
||||
return LOCAL_FALLBACK_CODES.has(code);
|
||||
}
|
||||
|
||||
export function isRetryable(code: AudioPipelineErrorCode): boolean {
|
||||
return RETRYABLE_CODES.has(code);
|
||||
}
|
||||
110
apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts
Normal file
110
apps/mobile-rn/src/features/knowledge/knowledge-realtime.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { AppState } from 'react-native'
|
||||
|
||||
/**
|
||||
* Realtime policy for the mobile Knowledge screen.
|
||||
*
|
||||
* Supabase Postgres Changes does not apply RLS to DELETE events and cannot
|
||||
* filter them, so an unfiltered DELETE subscription would deliver every
|
||||
* account's deleted primary keys to every subscriber and make each client
|
||||
* refetch on every deletion anywhere (N-client fan-out). Only INSERT/UPDATE are
|
||||
* subscribed (RLS-scoped, optionally narrowed to the owner), and deletions are
|
||||
* reconciled by foreground polling, mirroring use-history-sync.ts.
|
||||
*/
|
||||
|
||||
export type KnowledgeRealtimeEvent = 'INSERT' | 'UPDATE'
|
||||
|
||||
export interface KnowledgeRealtimeBinding {
|
||||
event: KnowledgeRealtimeEvent
|
||||
schema: 'public'
|
||||
table: 'knowledge_documents'
|
||||
filter?: string
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS = 45_000
|
||||
|
||||
const SUBSCRIBED_EVENTS: readonly KnowledgeRealtimeEvent[] = ['INSERT', 'UPDATE']
|
||||
|
||||
/**
|
||||
* Pure: the postgres_changes bindings the Knowledge screen may open. DELETE is
|
||||
* intentionally never included. `ownerId` must already be validated by the
|
||||
* caller because it is interpolated into the realtime filter.
|
||||
*/
|
||||
export function knowledgeRealtimeBindings(ownerId?: string): KnowledgeRealtimeBinding[] {
|
||||
return SUBSCRIBED_EVENTS.map((event) => ({
|
||||
event,
|
||||
schema: 'public',
|
||||
table: 'knowledge_documents',
|
||||
...(ownerId === undefined ? {} : { filter: `user_id=eq.${ownerId}` }),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Port: whether the app is in the foreground, and changes to that state. */
|
||||
export interface ForegroundStatePort {
|
||||
isActive: () => boolean
|
||||
onChange: (listener: (active: boolean) => void) => { remove: () => void }
|
||||
}
|
||||
|
||||
/** Port: interval scheduling, injectable for tests. */
|
||||
export interface IntervalPort {
|
||||
set: (callback: () => void, ms: number) => ReturnType<typeof setInterval>
|
||||
clear: (handle: ReturnType<typeof setInterval>) => void
|
||||
}
|
||||
|
||||
export interface ForegroundReconciliationDeps {
|
||||
foreground?: ForegroundStatePort
|
||||
interval?: IntervalPort
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
export interface ForegroundReconciliation {
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export const reactNativeForegroundState: ForegroundStatePort = {
|
||||
isActive: () => AppState.currentState === 'active',
|
||||
onChange: (listener) => {
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
listener(state === 'active')
|
||||
})
|
||||
return { remove: () => subscription.remove() }
|
||||
},
|
||||
}
|
||||
|
||||
const systemInterval: IntervalPort = {
|
||||
set: (callback, ms) => setInterval(callback, ms),
|
||||
clear: (handle) => clearInterval(handle),
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onReconcile` periodically while the app is in the foreground and once
|
||||
* whenever it returns to the foreground, so remote deletions are picked up
|
||||
* without a DELETE realtime subscription.
|
||||
*/
|
||||
export function startForegroundReconciliation(
|
||||
onReconcile: () => void,
|
||||
deps: ForegroundReconciliationDeps = {},
|
||||
): ForegroundReconciliation {
|
||||
const foreground = deps.foreground ?? reactNativeForegroundState
|
||||
const interval = deps.interval ?? systemInterval
|
||||
const intervalMs = deps.intervalMs ?? KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS
|
||||
let stopped = false
|
||||
let active = foreground.isActive()
|
||||
|
||||
const stateSubscription = foreground.onChange((nextActive) => {
|
||||
const resumed = nextActive && !active
|
||||
active = nextActive
|
||||
if (resumed && !stopped) onReconcile()
|
||||
})
|
||||
const handle = interval.set(() => {
|
||||
if (active && !stopped) onReconcile()
|
||||
}, intervalMs)
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
interval.clear(handle)
|
||||
stateSubscription.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,11 @@ import type {
|
|||
import { SUPABASE_URL } from '@d3ro/core/supabase-config'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import { createUuidV4 } from '../../lib/random-id'
|
||||
import {
|
||||
type ForegroundReconciliationDeps,
|
||||
knowledgeRealtimeBindings,
|
||||
startForegroundReconciliation,
|
||||
} from './knowledge-realtime'
|
||||
|
||||
export type MobileKnowledgeFileType = Extract<KnowledgeFileType, 'txt' | 'md'>
|
||||
|
||||
|
|
@ -607,31 +612,40 @@ export async function searchKnowledge(
|
|||
}
|
||||
}
|
||||
|
||||
export interface KnowledgeDocumentSubscriptionOptions {
|
||||
/**
|
||||
* Owner whose documents the list shows. When given, INSERT/UPDATE events are
|
||||
* narrowed to that owner so teammates' shared-document writes do not trigger
|
||||
* list refetches for a list that only shows owned documents.
|
||||
*/
|
||||
userId?: string
|
||||
/** Injectable foreground/interval ports for deletion reconciliation. */
|
||||
reconciliation?: ForegroundReconciliationDeps
|
||||
}
|
||||
|
||||
/**
|
||||
* Live updates for the Knowledge list. Only INSERT/UPDATE are subscribed:
|
||||
* Postgres Changes cannot filter DELETE events and does not apply RLS to them,
|
||||
* so a DELETE subscription would leak other accounts' document ids and fan out
|
||||
* a refetch to every client on every deletion. Deletions are reconciled by
|
||||
* foreground polling instead (same policy as use-history-sync.ts).
|
||||
*/
|
||||
export function subscribeToKnowledgeDocuments(
|
||||
onChanged: () => void,
|
||||
onStatus: (connected: boolean) => void,
|
||||
options: KnowledgeDocumentSubscriptionOptions = {},
|
||||
): KnowledgeDocumentSubscription {
|
||||
const channel: RealtimeChannel = supabase
|
||||
.channel(`mobile-knowledge-${createUuidV4()}`)
|
||||
.on('postgres_changes', {
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'knowledge_documents',
|
||||
}, onChanged)
|
||||
.on('postgres_changes', {
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'knowledge_documents',
|
||||
}, onChanged)
|
||||
.on('postgres_changes', {
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'knowledge_documents',
|
||||
}, onChanged)
|
||||
.subscribe((status) => onStatus(status === 'SUBSCRIBED'))
|
||||
if (options.userId !== undefined) requireUuid(options.userId, 'knowledge owner id')
|
||||
let channel: RealtimeChannel = supabase.channel(`mobile-knowledge-${createUuidV4()}`)
|
||||
for (const binding of knowledgeRealtimeBindings(options.userId)) {
|
||||
channel = channel.on('postgres_changes', binding, onChanged)
|
||||
}
|
||||
channel = channel.subscribe((status) => onStatus(status === 'SUBSCRIBED'))
|
||||
const reconciliation = startForegroundReconciliation(onChanged, options.reconciliation)
|
||||
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
reconciliation.stop()
|
||||
await supabase.removeChannel(channel)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import type { Meeting, MeetingStatus } from '@d3ro/api-client'
|
||||
|
||||
/**
|
||||
* Meeting states the server may answer with for each recording lifecycle
|
||||
* transition. Pure policy: the RPC wrappers in meetings-service only decide
|
||||
* whether a returned row confirms the transition they asked for.
|
||||
*
|
||||
* A capture that ends without a new transcript (cancelled, failed after
|
||||
* upload, discarded while queued) leaves a meeting that still holds content
|
||||
* from an earlier recording 'completed' (migrations 20260929002700,
|
||||
* 20260929040000); only a meeting without earlier content becomes 'error'.
|
||||
*/
|
||||
export type MeetingRecordingTransition =
|
||||
| 'recording-started'
|
||||
| 'recording-queued'
|
||||
| 'processing-completed'
|
||||
| 'capture-abandoned'
|
||||
|
||||
const EXPECTED_STATES: Readonly<Record<MeetingRecordingTransition, readonly MeetingStatus[]>> = {
|
||||
'recording-started': ['recording'],
|
||||
'recording-queued': ['processing'],
|
||||
'processing-completed': ['completed'],
|
||||
'capture-abandoned': ['error', 'completed'],
|
||||
}
|
||||
|
||||
export function expectedMeetingStates(
|
||||
transition: MeetingRecordingTransition,
|
||||
): readonly MeetingStatus[] {
|
||||
return EXPECTED_STATES[transition]
|
||||
}
|
||||
|
||||
/** True when `meeting` belongs to `userId` and is in a state `transition` may produce. */
|
||||
export function confirmsMeetingTransition(
|
||||
meeting: Pick<Meeting, 'user_id' | 'status'>,
|
||||
userId: string,
|
||||
transition: MeetingRecordingTransition,
|
||||
): boolean {
|
||||
return meeting.user_id === userId && EXPECTED_STATES[transition].includes(meeting.status)
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ import {
|
|||
type LinkedAudioRef,
|
||||
type LinkedAudioStore,
|
||||
} from '../audio/linked-audio-cleanup'
|
||||
import {
|
||||
confirmsMeetingTransition,
|
||||
type MeetingRecordingTransition,
|
||||
} from './meeting-recording-state-policy'
|
||||
|
||||
export interface MeetingListOptions {
|
||||
userId: string
|
||||
|
|
@ -267,6 +271,18 @@ function assertMeetingRow(row: Meeting | null, expectedId?: string): Meeting {
|
|||
return row
|
||||
}
|
||||
|
||||
function assertMeetingTransition(
|
||||
meeting: Meeting,
|
||||
userId: string,
|
||||
transition: MeetingRecordingTransition,
|
||||
message: string,
|
||||
): Meeting {
|
||||
if (!confirmsMeetingTransition(meeting, userId, transition)) {
|
||||
throw new MeetingServiceError('server', message)
|
||||
}
|
||||
return meeting
|
||||
}
|
||||
|
||||
function assertProcessingJobRow(row: ProcessingJob | null): ProcessingJob {
|
||||
if (row === null) {
|
||||
throw new MeetingServiceError('server', 'Processing job was not returned')
|
||||
|
|
@ -801,11 +817,12 @@ export async function beginMeetingRecording(
|
|||
'mobile_begin_meeting_recording',
|
||||
{ p_meeting_id: meetingId },
|
||||
)
|
||||
const meeting = assertMeetingRow(row, meetingId)
|
||||
if (meeting.user_id !== userId || meeting.status !== 'recording') {
|
||||
throw new MeetingServiceError('server', 'Meeting recording state was not confirmed')
|
||||
}
|
||||
return meeting
|
||||
return assertMeetingTransition(
|
||||
assertMeetingRow(row, meetingId),
|
||||
userId,
|
||||
'recording-started',
|
||||
'Meeting recording state was not confirmed',
|
||||
)
|
||||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
|
@ -859,11 +876,12 @@ export async function queueMeetingRecording(
|
|||
'mobile_queue_meeting_recording',
|
||||
{ p_meeting_id: meetingId, p_duration_ms: Math.round(durationMs) },
|
||||
)
|
||||
const meeting = assertMeetingRow(row, meetingId)
|
||||
if (meeting.user_id !== userId || meeting.status !== 'processing') {
|
||||
throw new MeetingServiceError('server', 'Queued meeting state was not confirmed')
|
||||
}
|
||||
return meeting
|
||||
return assertMeetingTransition(
|
||||
assertMeetingRow(row, meetingId),
|
||||
userId,
|
||||
'recording-queued',
|
||||
'Queued meeting state was not confirmed',
|
||||
)
|
||||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
|
@ -899,11 +917,12 @@ export async function completeMeetingProcessing(
|
|||
p_stt_latency_ms: Math.round(result.sttLatencyMs),
|
||||
},
|
||||
)
|
||||
const meeting = assertMeetingRow(row, meetingId)
|
||||
if (meeting.user_id !== userId || meeting.status !== 'completed') {
|
||||
throw new MeetingServiceError('server', 'Meeting completion was not confirmed')
|
||||
}
|
||||
return meeting
|
||||
return assertMeetingTransition(
|
||||
assertMeetingRow(row, meetingId),
|
||||
userId,
|
||||
'processing-completed',
|
||||
'Meeting completion was not confirmed',
|
||||
)
|
||||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
|
@ -952,11 +971,13 @@ export async function cancelMeetingRecording(
|
|||
'mobile_cancel_meeting_recording',
|
||||
{ p_meeting_id: meetingId },
|
||||
)
|
||||
const meeting = assertMeetingRow(row, meetingId)
|
||||
if (meeting.user_id !== userId || meeting.status !== 'error') {
|
||||
throw new MeetingServiceError('server', 'Meeting cancellation was not confirmed')
|
||||
}
|
||||
return meeting
|
||||
// A re-record of a meeting with earlier content returns to 'completed'.
|
||||
return assertMeetingTransition(
|
||||
assertMeetingRow(row, meetingId),
|
||||
userId,
|
||||
'capture-abandoned',
|
||||
'Meeting cancellation was not confirmed',
|
||||
)
|
||||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
|
@ -978,11 +999,13 @@ export async function failMeetingRecording(
|
|||
'mobile_fail_meeting_recording',
|
||||
{ p_meeting_id: meetingId, p_error_message: safeMessage },
|
||||
)
|
||||
const meeting = assertMeetingRow(row, meetingId)
|
||||
if (meeting.user_id !== userId || meeting.status !== 'error') {
|
||||
throw new MeetingServiceError('server', 'Meeting error state was not confirmed')
|
||||
}
|
||||
return meeting
|
||||
// A re-record of a meeting with earlier content returns to 'completed'.
|
||||
return assertMeetingTransition(
|
||||
assertMeetingRow(row, meetingId),
|
||||
userId,
|
||||
'capture-abandoned',
|
||||
'Meeting error state was not confirmed',
|
||||
)
|
||||
} catch (error) {
|
||||
throw toMeetingServiceError(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,12 @@ const listeners = new Set<(event: DurableQueueEvent) => void>();
|
|||
const runningControllers = new Map<string, AbortController>();
|
||||
const runningItemPromises = new Map<string, Promise<AudioPipelineResult>>();
|
||||
const discardedItems = new Set<string>();
|
||||
/**
|
||||
* Owners whose session was lost while their items stay parked on the device.
|
||||
* Timer-driven passes skip them so a signed-out owner's retries are not spent
|
||||
* on certain auth failures; resumeQueuedAudioForUser (sign-in) lifts it.
|
||||
*/
|
||||
const suspendedUsers = new Set<string>();
|
||||
|
||||
function emit(event: DurableQueueEvent): void {
|
||||
for (const listener of listeners) listener(event);
|
||||
|
|
@ -458,11 +464,13 @@ export async function processQueuedAudioNow(
|
|||
* overwritten from a stale snapshot.
|
||||
*/
|
||||
async function runResumePass(userId: string): Promise<void> {
|
||||
if (suspendedUsers.has(userId)) return;
|
||||
const order = (await readQueue())
|
||||
.filter(item => item.userId === userId)
|
||||
.sort((left, right) => left.createdAtMs - right.createdAtMs)
|
||||
.map(item => item.id);
|
||||
for (const itemId of order) {
|
||||
if (suspendedUsers.has(userId)) return;
|
||||
const item = await repository.get(itemId);
|
||||
if (item === undefined || item.userId !== userId) continue;
|
||||
if (item.status === 'terminal') continue;
|
||||
|
|
@ -493,6 +501,7 @@ async function runResumePass(userId: string): Promise<void> {
|
|||
* pass after it, and the returned promise settles when that pass is done.
|
||||
*/
|
||||
export function resumeQueuedAudioForUser(userId: string): Promise<void> {
|
||||
suspendedUsers.delete(userId);
|
||||
return scheduler.requestRun(userId);
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +554,28 @@ export async function clearQueuedAudioForUser(userId: string): Promise<void> {
|
|||
if (firstRemovalError !== null) throw firstRemovalError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Involuntary session loss: keeps every item of `ownerUserId` (files and
|
||||
* entries, whatever their status) and suspends its retries until that owner
|
||||
* signs back in; items of any other account are discarded as usual.
|
||||
*/
|
||||
export async function retainQueuedAudioOnlyForUser(ownerUserId: string): Promise<void> {
|
||||
suspendedUsers.add(ownerUserId);
|
||||
scheduler.cancel(ownerUserId);
|
||||
const otherUserIds = [...new Set((await readQueue())
|
||||
.map(item => item.userId)
|
||||
.filter(userId => userId !== ownerUserId))];
|
||||
let firstError: unknown = null;
|
||||
for (const userId of otherUserIds) {
|
||||
try {
|
||||
await clearQueuedAudioForUser(userId);
|
||||
} catch (error) {
|
||||
firstError ??= error;
|
||||
}
|
||||
}
|
||||
if (firstError !== null) throw firstError;
|
||||
}
|
||||
|
||||
export async function clearAllQueuedAudio(): Promise<void> {
|
||||
const userIds = [...new Set((await readQueue()).map(item => item.userId))];
|
||||
let firstError: unknown = null;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config'
|
||||
import { FileSystem } from 'react-native-file-access'
|
||||
import type { RecordedAudio } from '../../lib/audio-recorder'
|
||||
import { createLogger } from '../../lib/logger'
|
||||
import {
|
||||
AudioPipelineError,
|
||||
type LocalAudioInput,
|
||||
type MobileTranscriptionResult,
|
||||
} from '../import/audio-import-types'
|
||||
import { decodeBase64 } from '../import/audio-validation'
|
||||
import { createMultipartAudioBody } from '../import/multipart-audio'
|
||||
import { prepareRecordedAudio } from '../import/recorded-audio-input'
|
||||
import { transcribeAudioLocally } from '../import/local-whisper-transcription'
|
||||
import { requireSttAccessToken, transcribeCloud } from '../import/stt-cloud-client'
|
||||
import { transcribeWithLocalFallback } from '../import/stt-engine'
|
||||
|
||||
interface TalkTranscriptionOptions {
|
||||
accessToken: string
|
||||
|
|
@ -20,32 +21,7 @@ interface TalkTranscriptionOptions {
|
|||
}
|
||||
|
||||
const DEFAULT_STT_TIMEOUT_MS = 120_000
|
||||
|
||||
interface LinkedDeadline {
|
||||
signal: AbortSignal
|
||||
didTimeOut: () => boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function linkedDeadline(parent: AbortSignal, timeoutMs: number): LinkedDeadline {
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
const abortFromParent = (): void => controller.abort()
|
||||
parent.addEventListener('abort', abortFromParent, { once: true })
|
||||
if (parent.aborted) controller.abort()
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
controller.abort()
|
||||
}, timeoutMs)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
didTimeOut: () => timedOut,
|
||||
dispose: () => {
|
||||
clearTimeout(timer)
|
||||
parent.removeEventListener('abort', abortFromParent)
|
||||
},
|
||||
}
|
||||
}
|
||||
const log = createLogger('Talk')
|
||||
|
||||
function ensureNotCancelled(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
|
|
@ -53,38 +29,8 @@ function ensureNotCancelled(signal: AbortSignal): void {
|
|||
}
|
||||
}
|
||||
|
||||
function parseCloudResult(value: unknown, latencyMs: number): MobileTranscriptionResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new AudioPipelineError('transcription', 'The speech service returned invalid data')
|
||||
}
|
||||
const body = value as Record<string, unknown>
|
||||
if (
|
||||
typeof body.transcript !== 'string'
|
||||
|| body.transcript.trim().length === 0
|
||||
|| typeof body.language_code !== 'string'
|
||||
|| body.language_code.trim().length === 0
|
||||
|| typeof body.duration_seconds !== 'number'
|
||||
|| !Number.isFinite(body.duration_seconds)
|
||||
|| body.duration_seconds < 0
|
||||
|| typeof body.provider !== 'string'
|
||||
|| body.provider.trim().length === 0
|
||||
) {
|
||||
throw new AudioPipelineError('transcription', 'The speech service returned incomplete data')
|
||||
}
|
||||
|
||||
const confidence = typeof body.confidence === 'number' && Number.isFinite(body.confidence)
|
||||
? Math.max(0, Math.min(1, body.confidence))
|
||||
: null
|
||||
return {
|
||||
text: body.transcript.trim(),
|
||||
confidence,
|
||||
language: body.language_code.trim(),
|
||||
durationSeconds: body.duration_seconds,
|
||||
provider: body.provider.trim(),
|
||||
latencyMs,
|
||||
}
|
||||
}
|
||||
|
||||
// Talk reads the recording itself so the "file changed under us" size check
|
||||
// stays next to the read, before any byte is sent to the cloud.
|
||||
async function readBytes(input: LocalAudioInput): Promise<Uint8Array> {
|
||||
try {
|
||||
const bytes = decodeBase64(await FileSystem.readFile(input.path, 'base64'))
|
||||
|
|
@ -98,70 +44,22 @@ async function readBytes(input: LocalAudioInput): Promise<Uint8Array> {
|
|||
}
|
||||
}
|
||||
|
||||
async function transcribeCloud(
|
||||
async function transcribeTalkCloud(
|
||||
input: LocalAudioInput,
|
||||
accessToken: string,
|
||||
languageCode: string,
|
||||
signal: AbortSignal,
|
||||
timeoutMs: number,
|
||||
options: TalkTranscriptionOptions,
|
||||
): Promise<MobileTranscriptionResult> {
|
||||
const token = accessToken.trim()
|
||||
if (token.length === 0) throw new AudioPipelineError('auth', 'Sign in to transcribe speech')
|
||||
ensureNotCancelled(signal)
|
||||
const accessToken = requireSttAccessToken(options.accessToken)
|
||||
ensureNotCancelled(options.signal)
|
||||
const bytes = await readBytes(input)
|
||||
ensureNotCancelled(signal)
|
||||
const multipart = createMultipartAudioBody(input, bytes, languageCode)
|
||||
const deadline = linkedDeadline(signal, timeoutMs)
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SUPABASE_URL}/functions/v1/stt-proxy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`,
|
||||
},
|
||||
body: multipart.body,
|
||||
signal: deadline.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AudioPipelineError('auth', 'The speech session is no longer authorized')
|
||||
}
|
||||
if (response.status === 413) {
|
||||
throw new AudioPipelineError('file-too-large', 'The recording is too large')
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new AudioPipelineError('quota', 'The speech transcription quota is exhausted')
|
||||
}
|
||||
if (response.status === 502 || response.status === 503 || response.status === 504) {
|
||||
// 진단: 엣지함수 본문(stt_provider_unavailable=시크릿 없음 vs
|
||||
// stt_upstream_failed=제공자 실패)을 로그에서 구분할 수 있게 남긴다.
|
||||
const bodyText = await response.text().catch(() => '')
|
||||
throw new AudioPipelineError(
|
||||
'provider-unavailable',
|
||||
`Cloud STT unavailable (HTTP ${response.status}: ${bodyText.replace(/\s+/g, ' ').slice(0, 160)})`,
|
||||
)
|
||||
}
|
||||
throw new AudioPipelineError(
|
||||
'transcription',
|
||||
`Speech transcription failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
return parseCloudResult(await response.json(), Date.now() - startedAt)
|
||||
} catch (error) {
|
||||
if (error instanceof AudioPipelineError) throw error
|
||||
if (signal.aborted) {
|
||||
throw new AudioPipelineError('cancelled', 'Talk transcription was cancelled', error)
|
||||
}
|
||||
if (deadline.didTimeOut()) {
|
||||
throw new AudioPipelineError('provider-unavailable', 'Cloud speech recognition timed out', error)
|
||||
}
|
||||
throw new AudioPipelineError('transcription', 'Cloud speech recognition could not be reached', error)
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
ensureNotCancelled(options.signal)
|
||||
return transcribeCloud({
|
||||
input,
|
||||
bytes,
|
||||
accessToken,
|
||||
languageCode: options.languageCode,
|
||||
signal: options.signal,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_STT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function transcribeTalkRecording(
|
||||
|
|
@ -172,59 +70,18 @@ export async function transcribeTalkRecording(
|
|||
let primaryError: unknown = null
|
||||
|
||||
try {
|
||||
try {
|
||||
return await transcribeCloud(
|
||||
input,
|
||||
options.accessToken,
|
||||
options.languageCode,
|
||||
options.signal,
|
||||
options.timeoutMs ?? DEFAULT_STT_TIMEOUT_MS,
|
||||
)
|
||||
} catch (cloudError) {
|
||||
if (
|
||||
!(cloudError instanceof AudioPipelineError)
|
||||
|| cloudError.code === 'auth'
|
||||
|| cloudError.code === 'cancelled'
|
||||
|| cloudError.code === 'quota'
|
||||
|| cloudError.code === 'file-too-large'
|
||||
) {
|
||||
throw cloudError
|
||||
}
|
||||
return await transcribeWithLocalFallback({
|
||||
input,
|
||||
languageCode: options.languageCode,
|
||||
signal: options.signal,
|
||||
cloud: () => transcribeTalkCloud(input, options),
|
||||
local: transcribeAudioLocally,
|
||||
// 진단: 클라우드가 실패해 기기 whisper로 폴백하는 이유를 남긴다.
|
||||
console.warn(
|
||||
'[Talk] cloud STT failed, falling back to on-device whisper:',
|
||||
`${(cloudError as AudioPipelineError).code}: ${cloudError.message}`,
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await transcribeAudioLocally(
|
||||
input,
|
||||
options.languageCode,
|
||||
options.signal,
|
||||
)
|
||||
if (result.text.trim().length === 0) {
|
||||
// 기기 whisper는 실행됐지만 한국어 등을 인식하지 못한 경우 —
|
||||
// "모두 실패"가 아니다. 클라우드 실패 원인도 원인 체인에 남긴다.
|
||||
throw new AudioPipelineError(
|
||||
'no-speech',
|
||||
'No speech was detected in the recording',
|
||||
cloudError,
|
||||
)
|
||||
}
|
||||
return result
|
||||
} catch (localError) {
|
||||
if (localError instanceof AudioPipelineError && localError.code === 'cancelled') {
|
||||
throw localError
|
||||
}
|
||||
throw new AudioPipelineError(
|
||||
localError instanceof AudioPipelineError ? localError.code : 'transcription',
|
||||
localError instanceof Error
|
||||
? localError.message
|
||||
: 'Cloud and on-device speech recognition both failed',
|
||||
{ cloudError, localError },
|
||||
)
|
||||
}
|
||||
}
|
||||
onFallback: cloudError => log.warn(
|
||||
'cloud STT failed, falling back to on-device whisper:',
|
||||
`${cloudError.code}: ${cloudError.message}`,
|
||||
),
|
||||
})
|
||||
} catch (error) {
|
||||
primaryError = error
|
||||
throw error
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { RealtimeChannel } from '@supabase/supabase-js'
|
|||
import type { Meeting, Team, TeamRole } from '@d3ro/api-client'
|
||||
import { PUBLIC_SITE_ORIGIN, SITE_URLS } from '@d3ro/core/web-urls'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import { invokeEdgeFunction, type EdgeFunctionFailure } from '../../lib/edge-functions'
|
||||
|
||||
export interface TeamSummary extends Team {
|
||||
role: TeamRole
|
||||
|
|
@ -407,23 +408,26 @@ async function invokeRpc(name: string, params: Record<string, unknown>): Promise
|
|||
}
|
||||
}
|
||||
|
||||
async function readFunctionFailure(error: unknown): Promise<TeamServiceError> {
|
||||
const context = (error as { context?: unknown })?.context
|
||||
if (context instanceof Response) {
|
||||
try {
|
||||
const payload: unknown = await context.clone().json()
|
||||
if (isRecord(payload)) {
|
||||
const code = typeof payload.error === 'string' ? payload.error : ''
|
||||
const message = typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: code || `Team function returned ${context.status}`
|
||||
return toTeamServiceError({ code, message })
|
||||
}
|
||||
} catch {
|
||||
return toTeamServiceError({ message: `Team function returned ${context.status}` })
|
||||
/**
|
||||
* Team Edge Functions put the machine code in `error` and a human string in
|
||||
* `message`. The transport decoding lives in lib/edge-functions; this keeps
|
||||
* only the team-specific field selection and fallbacks.
|
||||
*/
|
||||
function readFunctionFailure(failure: EdgeFunctionFailure): TeamServiceError {
|
||||
if (failure.status !== null && failure.body !== null) {
|
||||
if (!failure.body.readable) {
|
||||
return toTeamServiceError({ message: `Team function returned ${failure.status}` })
|
||||
}
|
||||
const payload = failure.body.payload
|
||||
if (isRecord(payload)) {
|
||||
const code = typeof payload.error === 'string' ? payload.error : ''
|
||||
const message = typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: code || `Team function returned ${failure.status}`
|
||||
return toTeamServiceError({ code, message })
|
||||
}
|
||||
}
|
||||
return toTeamServiceError(error)
|
||||
return toTeamServiceError(failure.cause)
|
||||
}
|
||||
|
||||
export async function listTeams(userId: string): Promise<TeamSummary[]> {
|
||||
|
|
@ -705,10 +709,9 @@ export async function createTeamInvite(
|
|||
throw new TeamServiceError('validation', 'Invite role is invalid')
|
||||
}
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('team-invite', {
|
||||
body: { team_id: teamId, email, role },
|
||||
})
|
||||
if (error !== null) throw await readFunctionFailure(error)
|
||||
const result = await invokeEdgeFunction('team-invite', { team_id: teamId, email, role })
|
||||
if (!result.ok) throw readFunctionFailure(result)
|
||||
const data = result.data
|
||||
if (!isRecord(data)) {
|
||||
throw new TeamServiceError('invalid-response', 'Invite response is not an object')
|
||||
}
|
||||
|
|
@ -746,10 +749,9 @@ export async function cancelTeamInvite(teamId: string, inviteId: string): Promis
|
|||
export async function acceptTeamInvite(tokenValue: string): Promise<TeamAcceptResult> {
|
||||
const token = normalizeInviteToken(tokenValue)
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('team-accept', {
|
||||
body: { token },
|
||||
})
|
||||
if (error !== null) throw await readFunctionFailure(error)
|
||||
const result = await invokeEdgeFunction('team-accept', { token })
|
||||
if (!result.ok) throw readFunctionFailure(result)
|
||||
const data = result.data
|
||||
if (!isRecord(data)) {
|
||||
throw new TeamServiceError('invalid-response', 'Accept response is not an object')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import { clearAllActionHistories } from '../features/actions/action-service'
|
||||
import { clearAllHistoryCaches } from '../features/history/history-cache'
|
||||
import { deleteNativePushRegistration } from '../features/notifications/notification-native'
|
||||
import { clearAllQueuedAudio } from '../features/recording/durable-processing-queue'
|
||||
import {
|
||||
clearAllQueuedAudio,
|
||||
retainQueuedAudioOnlyForUser,
|
||||
} from '../features/recording/durable-processing-queue'
|
||||
import { clearEveryGenerationIdempotencyKey } from '../features/templates'
|
||||
import { audioRecorder } from './audio-recorder'
|
||||
import {
|
||||
DISCARD_UNSYNCED_WORK,
|
||||
unsyncedWorkDispositionKey,
|
||||
type UnsyncedWorkDisposition,
|
||||
} from './auth-transition-policy'
|
||||
import { clearAllEntitlementCaches } from './entitlement-context'
|
||||
import { clearAllUserPreferenceCaches } from './preferences-context'
|
||||
import { stopLiveCaptureKeepingFile } from './retain-live-capture'
|
||||
import { retainedAccountWork } from './retained-account-work'
|
||||
|
||||
export class AccountLocalDataPurgeError extends Error {
|
||||
readonly code = 'account_local_data_purge_failed'
|
||||
|
|
@ -16,34 +26,88 @@ export class AccountLocalDataPurgeError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
let purgeInFlight: Promise<void> | null = null
|
||||
type PurgeTask = () => Promise<void>
|
||||
|
||||
/** Account-scoped caches: always removed, they can be rebuilt from the server. */
|
||||
const ACCOUNT_CACHE_TASKS: readonly PurgeTask[] = [
|
||||
clearAllHistoryCaches,
|
||||
clearAllEntitlementCaches,
|
||||
clearAllUserPreferenceCaches,
|
||||
clearAllActionHistories,
|
||||
clearEveryGenerationIdempotencyKey,
|
||||
deleteNativePushRegistration,
|
||||
]
|
||||
|
||||
/**
|
||||
* Removes every account-scoped local artifact before an auth boundary opens.
|
||||
* Unsynced user-owned work (the live capture and durable-queue recordings) is
|
||||
* the only copy of that audio. It is deleted only for a discard boundary; on a
|
||||
* retain boundary the microphone is stopped, the owner's files are kept and
|
||||
* the owner is recorded so a different account can still never see them.
|
||||
*/
|
||||
function unsyncedWorkTasks(disposition: UnsyncedWorkDisposition): PurgeTask[] {
|
||||
if (disposition.kind === 'discard') {
|
||||
return [
|
||||
() => audioRecorder.cancel(),
|
||||
clearAllQueuedAudio,
|
||||
() => retainedAccountWork.release(),
|
||||
]
|
||||
}
|
||||
const owner = disposition.ownerUserId
|
||||
return [
|
||||
() => retainedAccountWork.retain(owner),
|
||||
() => stopLiveCaptureKeepingFile(audioRecorder),
|
||||
() => retainQueuedAudioOnlyForUser(owner),
|
||||
]
|
||||
}
|
||||
|
||||
function startTask(task: PurgeTask): Promise<void> {
|
||||
try {
|
||||
return Promise.resolve(task())
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPurge(disposition: UnsyncedWorkDisposition): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
[...ACCOUNT_CACHE_TASKS, ...unsyncedWorkTasks(disposition)].map(startTask),
|
||||
)
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
throw new AccountLocalDataPurgeError()
|
||||
}
|
||||
}
|
||||
|
||||
interface PurgeRun {
|
||||
key: string
|
||||
promise: Promise<void>
|
||||
}
|
||||
|
||||
let purgeInFlight: PurgeRun | null = null
|
||||
|
||||
/**
|
||||
* Removes account-scoped local artifacts before an auth boundary opens.
|
||||
* All tasks are allowed to settle so an early failure cannot skip deletion of
|
||||
* unrelated caches or raw audio; callers receive only a stable public code.
|
||||
*
|
||||
* Concurrent calls with the same disposition share one run; a call with a
|
||||
* different disposition (for example a sign-out immediately replaced by
|
||||
* another account) runs after the current one so its stricter cleanup is
|
||||
* never swallowed by the earlier, weaker one.
|
||||
*/
|
||||
export function purgeAllAccountLocalData(): Promise<void> {
|
||||
if (purgeInFlight !== null) return purgeInFlight
|
||||
export function purgeAllAccountLocalData(
|
||||
unsyncedWork: UnsyncedWorkDisposition = DISCARD_UNSYNCED_WORK,
|
||||
): Promise<void> {
|
||||
const key = unsyncedWorkDispositionKey(unsyncedWork)
|
||||
const previous = purgeInFlight
|
||||
if (previous !== null && previous.key === key) return previous.promise
|
||||
|
||||
const operation = (async (): Promise<void> => {
|
||||
const results = await Promise.allSettled([
|
||||
clearAllHistoryCaches(),
|
||||
clearAllEntitlementCaches(),
|
||||
clearAllUserPreferenceCaches(),
|
||||
audioRecorder.cancel(),
|
||||
clearAllQueuedAudio(),
|
||||
clearAllActionHistories(),
|
||||
clearEveryGenerationIdempotencyKey(),
|
||||
deleteNativePushRegistration(),
|
||||
])
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
throw new AccountLocalDataPurgeError()
|
||||
}
|
||||
})().finally(() => {
|
||||
if (purgeInFlight === operation) purgeInFlight = null
|
||||
const run = previous === null
|
||||
? runPurge(unsyncedWork)
|
||||
: previous.promise.catch(() => undefined).then(() => runPurge(unsyncedWork))
|
||||
const promise: Promise<void> = run.finally(() => {
|
||||
if (purgeInFlight?.promise === promise) purgeInFlight = null
|
||||
})
|
||||
|
||||
purgeInFlight = operation
|
||||
return operation
|
||||
purgeInFlight = { key, promise }
|
||||
return promise
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { supabase, isSupabaseConfigured } from './supabase'
|
|||
import { completeAuthRedirect, isAuthRedirectUrl } from './auth-redirect'
|
||||
import { clearAllSecureAuthStorage } from './secure-auth-storage'
|
||||
import { purgeAllAccountLocalData } from './account-local-data'
|
||||
import { DISCARD_UNSYNCED_WORK, planAuthTransition } from './auth-transition-policy'
|
||||
import { retainedAccountWork } from './retained-account-work'
|
||||
|
||||
export type AuthPrivacyCleanupState = 'ready' | 'purging' | 'failed'
|
||||
|
||||
|
|
@ -106,12 +108,19 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
): Promise<boolean> => {
|
||||
const generation = ++transitionGenerationRef.current
|
||||
const previousUserId = committedSessionRef.current?.user.id ?? null
|
||||
const nextUserId = transition.session?.user.id ?? null
|
||||
const needsPurge = transition.forcePurge
|
||||
|| privacyCleanupStateRef.current !== 'ready'
|
||||
|| (previousUserId !== null && previousUserId !== nextUserId)
|
||||
const plan = planAuthTransition({
|
||||
previousUserId,
|
||||
nextUserId: transition.session?.user.id ?? null,
|
||||
retainedOwnerUserId: retainedAccountWork.current(),
|
||||
forcePurge: transition.forcePurge,
|
||||
cleanupPending: privacyCleanupStateRef.current !== 'ready',
|
||||
explicit: transition.explicit,
|
||||
})
|
||||
|
||||
if (!needsPurge) {
|
||||
if (!plan.purge) {
|
||||
// The owner of work kept by an earlier session loss is back: the queue
|
||||
// hook and Record screen resume it, so the retention marker can go.
|
||||
if (plan.releaseRetainedWork) void retainedAccountWork.release().catch(() => undefined)
|
||||
pendingTransitionRef.current = null
|
||||
commitSession(transition.session, transition.event)
|
||||
updatePrivacyCleanupState('ready')
|
||||
|
|
@ -122,7 +131,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
updatePrivacyCleanupState('purging')
|
||||
|
||||
try {
|
||||
await purgeAllAccountLocalData()
|
||||
await purgeAllAccountLocalData(plan.unsyncedWork)
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
|
||||
if (transition.clearSecureAuth) {
|
||||
|
|
@ -130,21 +139,31 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
// A SIGNED_OUT notification can race with a direct A -> B account
|
||||
// replacement. Preserve a newer different-account session that the
|
||||
// auth client has already committed to secure storage.
|
||||
let replacementSession: Session | null = null
|
||||
try {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
const latestSession = data.session
|
||||
const latestUserId = latestSession?.user.id ?? null
|
||||
if (latestSession !== null && latestUserId !== previousUserId) {
|
||||
pendingTransitionRef.current = null
|
||||
commitSession(latestSession, 'SIGNED_IN')
|
||||
updatePrivacyCleanupState('ready')
|
||||
return true
|
||||
replacementSession = latestSession
|
||||
}
|
||||
} catch {
|
||||
// If the auth client cannot prove that a newer session exists,
|
||||
// fail closed by removing the signed-out account's secure data.
|
||||
}
|
||||
if (replacementSession !== null) {
|
||||
if (plan.unsyncedWork.kind === 'retain') {
|
||||
// Work kept for the signed-out account must not stay on a
|
||||
// device that another account has already taken over.
|
||||
await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
|
||||
if (generation !== transitionGenerationRef.current) return false
|
||||
}
|
||||
pendingTransitionRef.current = null
|
||||
commitSession(replacementSession, 'SIGNED_IN')
|
||||
updatePrivacyCleanupState('ready')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -228,6 +247,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
|
|||
}
|
||||
|
||||
try {
|
||||
// The retained-work owner must be known before the first boundary
|
||||
// decides between keeping and discarding unsynced recordings.
|
||||
await retainedAccountWork.load().catch(() => null)
|
||||
const initialUrl = await Linking.getInitialURL()
|
||||
if (initialUrl !== null) await handleUrl(initialUrl)
|
||||
const { data: { session: restoredSession }, error } = await supabase.auth.getSession()
|
||||
|
|
|
|||
|
|
@ -6,22 +6,19 @@ export const AUTH_REDIRECT_URL = 'd3ro-voice://auth-callback'
|
|||
|
||||
export type AuthRedirectType = 'recovery' | 'signed-in' | 'cancelled' | 'ignored'
|
||||
|
||||
/**
|
||||
* The only auth operation a callback may drive. A session is created solely by
|
||||
* a PKCE code exchange, which the auth server binds to the code verifier this
|
||||
* device stored when it started the flow.
|
||||
*/
|
||||
type AuthRedirectOperations = {
|
||||
exchangeCodeForSession: typeof supabase.auth.exchangeCodeForSession
|
||||
setSession: typeof supabase.auth.setSession
|
||||
}
|
||||
|
||||
type ParsedAuthRedirect =
|
||||
| { kind: 'ignored' }
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'code'; code: string; recovery: boolean; replayKey: string }
|
||||
| {
|
||||
kind: 'tokens'
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
recovery: boolean
|
||||
replayKey: string
|
||||
}
|
||||
|
||||
export class AuthRedirectError extends Error {
|
||||
readonly code: 'invalid_callback' | 'provider_error'
|
||||
|
|
@ -46,8 +43,8 @@ function hasAuthMaterial(parsedUrl: URL): boolean {
|
|||
|| fragment.has('error_description')
|
||||
}
|
||||
|
||||
function replayFingerprint(kind: 'code' | 'tokens', secret: string): string {
|
||||
return `${kind}:${bytesToHex(sha256(utf8ToBytes(secret)))}`
|
||||
function replayFingerprint(secret: string): string {
|
||||
return `code:${bytesToHex(sha256(utf8ToBytes(secret)))}`
|
||||
}
|
||||
|
||||
export function isAuthRedirectUrl(url: string): boolean {
|
||||
|
|
@ -89,34 +86,23 @@ function parseAuthRedirect(url: string): ParsedAuthRedirect {
|
|||
throw new AuthRedirectError('provider_error')
|
||||
}
|
||||
|
||||
const code = query.get('code')
|
||||
const accessToken = fragment.get('access_token')
|
||||
const refreshToken = fragment.get('refresh_token')
|
||||
const hasCode = typeof code === 'string' && code.length > 0
|
||||
const hasAccessToken = typeof accessToken === 'string' && accessToken.length > 0
|
||||
const hasRefreshToken = typeof refreshToken === 'string' && refreshToken.length > 0
|
||||
// Implicit-flow session material (#access_token/#refresh_token) is never
|
||||
// accepted. The client runs every flow as PKCE, so a legitimate callback
|
||||
// carries only ?code=. A bare token pair is bound to nothing this device
|
||||
// started: any web page or app could open it and sign the device into
|
||||
// another account (login CSRF), which would also trigger the account-switch
|
||||
// purge of this user's local data.
|
||||
if (fragment.has('access_token') || fragment.has('refresh_token')) {
|
||||
throw new AuthRedirectError('invalid_callback')
|
||||
}
|
||||
|
||||
if (hasCode && (hasAccessToken || hasRefreshToken)) {
|
||||
throw new AuthRedirectError('invalid_callback')
|
||||
}
|
||||
if (hasAccessToken !== hasRefreshToken) {
|
||||
throw new AuthRedirectError('invalid_callback')
|
||||
}
|
||||
if (hasCode) {
|
||||
const code = query.get('code')
|
||||
if (typeof code === 'string' && code.length > 0) {
|
||||
return {
|
||||
kind: 'code',
|
||||
code,
|
||||
recovery: query.get('type') === 'recovery',
|
||||
replayKey: replayFingerprint('code', code),
|
||||
}
|
||||
}
|
||||
if (hasAccessToken && hasRefreshToken) {
|
||||
return {
|
||||
kind: 'tokens',
|
||||
accessToken,
|
||||
refreshToken,
|
||||
recovery: fragment.get('type') === 'recovery',
|
||||
replayKey: replayFingerprint('tokens', `${accessToken}\u0000${refreshToken}`),
|
||||
replayKey: replayFingerprint(code),
|
||||
}
|
||||
}
|
||||
return { kind: 'ignored' }
|
||||
|
|
@ -146,21 +132,12 @@ export function createAuthRedirectHandler(
|
|||
if (existing !== undefined) return existing
|
||||
|
||||
const operation = (async (): Promise<AuthRedirectType> => {
|
||||
if (parsed.kind === 'code') {
|
||||
const { data, error } = await auth.exchangeCodeForSession(parsed.code)
|
||||
if (error !== null) throw error
|
||||
const redirectData = data as typeof data & { redirectType?: string | null }
|
||||
return parsed.recovery || redirectData.redirectType === 'recovery'
|
||||
? 'recovery'
|
||||
: 'signed-in'
|
||||
}
|
||||
|
||||
const { error } = await auth.setSession({
|
||||
access_token: parsed.accessToken,
|
||||
refresh_token: parsed.refreshToken,
|
||||
})
|
||||
const { data, error } = await auth.exchangeCodeForSession(parsed.code)
|
||||
if (error !== null) throw error
|
||||
return parsed.recovery ? 'recovery' : 'signed-in'
|
||||
const redirectData = data as typeof data & { redirectType?: string | null }
|
||||
return parsed.recovery || redirectData.redirectType === 'recovery'
|
||||
? 'recovery'
|
||||
: 'signed-in'
|
||||
})()
|
||||
|
||||
inFlight.set(parsed.replayKey, operation)
|
||||
|
|
|
|||
69
apps/mobile-rn/src/lib/auth-transition-policy.ts
Normal file
69
apps/mobile-rn/src/lib/auth-transition-policy.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Pure policy for the mobile auth privacy boundary: given who was signed in,
|
||||
* who is signing in, and whose unsynced work is parked on the device, decide
|
||||
* whether local account data must be purged and what happens to the user's
|
||||
* unsynced recordings (durable-queue items and the live capture file).
|
||||
*
|
||||
* Caches are always purged when the boundary runs. Unsynced work is different:
|
||||
* it is the only copy of the user's audio, and queue items are already keyed
|
||||
* by their owner, so it is kept across an involuntary session loss (a revoked
|
||||
* refresh token, a password change on another device, a cold boot without a
|
||||
* session) and deleted only when another account takes the device or the user
|
||||
* signs out on purpose.
|
||||
*/
|
||||
|
||||
export type UnsyncedWorkDisposition =
|
||||
| { readonly kind: 'discard' }
|
||||
| { readonly kind: 'retain'; readonly ownerUserId: string }
|
||||
|
||||
export const DISCARD_UNSYNCED_WORK: UnsyncedWorkDisposition = Object.freeze({ kind: 'discard' })
|
||||
|
||||
export interface AuthTransitionFacts {
|
||||
/** Account whose session is currently committed to the UI. */
|
||||
previousUserId: string | null
|
||||
/** Account the transition would commit, or null for a signed-out state. */
|
||||
nextUserId: string | null
|
||||
/** Account whose unsynced work was kept by an earlier session loss. */
|
||||
retainedOwnerUserId: string | null
|
||||
/** The caller requires the boundary even for an unchanged account. */
|
||||
forcePurge: boolean
|
||||
/** An earlier boundary has not completed yet. */
|
||||
cleanupPending: boolean
|
||||
/** The user asked to sign out (logout, account deletion, device revoke). */
|
||||
explicit: boolean
|
||||
}
|
||||
|
||||
export type AuthTransitionPlan =
|
||||
| {
|
||||
readonly purge: false
|
||||
/** The retained work's owner is back; the retention marker can go. */
|
||||
readonly releaseRetainedWork: boolean
|
||||
}
|
||||
| { readonly purge: true; readonly unsyncedWork: UnsyncedWorkDisposition }
|
||||
|
||||
export function planAuthTransition(facts: AuthTransitionFacts): AuthTransitionPlan {
|
||||
const workOwner = facts.previousUserId ?? facts.retainedOwnerUserId
|
||||
const accountLeaves = facts.previousUserId !== null && facts.previousUserId !== facts.nextUserId
|
||||
const otherAccountArrives = facts.nextUserId !== null
|
||||
&& workOwner !== null
|
||||
&& workOwner !== facts.nextUserId
|
||||
|
||||
if (!facts.forcePurge && !facts.cleanupPending && !accountLeaves && !otherAccountArrives) {
|
||||
return {
|
||||
purge: false,
|
||||
releaseRetainedWork: facts.retainedOwnerUserId !== null
|
||||
&& facts.retainedOwnerUserId === facts.nextUserId,
|
||||
}
|
||||
}
|
||||
|
||||
const involuntarySessionLoss = facts.nextUserId === null && !facts.explicit
|
||||
if (involuntarySessionLoss && workOwner !== null) {
|
||||
return { purge: true, unsyncedWork: { kind: 'retain', ownerUserId: workOwner } }
|
||||
}
|
||||
return { purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK }
|
||||
}
|
||||
|
||||
/** Stable key so concurrent boundaries with the same disposition share one run. */
|
||||
export function unsyncedWorkDispositionKey(disposition: UnsyncedWorkDisposition): string {
|
||||
return disposition.kind === 'retain' ? `retain:${disposition.ownerUserId}` : 'discard'
|
||||
}
|
||||
|
|
@ -27,7 +27,12 @@ import {
|
|||
type EntitlementSnapshot,
|
||||
type MobileSubscriptionTier,
|
||||
} from './entitlement-context'
|
||||
import { supabase } from './supabase'
|
||||
import {
|
||||
invokeEdgeFunction,
|
||||
readEdgeFunctionHttpFailure,
|
||||
type EdgeFunctionFailure,
|
||||
type EdgeFunctionHttpBody,
|
||||
} from './edge-functions'
|
||||
import { getMobileRuntimeConfig } from './native-config'
|
||||
|
||||
export const GOOGLE_PLAY_SUBSCRIPTION_IDS = [
|
||||
|
|
@ -256,21 +261,37 @@ export async function restoreGooglePlayPurchaseSet(
|
|||
return refreshed
|
||||
}
|
||||
|
||||
async function functionErrorCode(candidate: unknown): Promise<string> {
|
||||
if (candidate && typeof candidate === 'object' && 'context' in candidate) {
|
||||
const context = (candidate as { context?: unknown }).context
|
||||
if (context instanceof Response) {
|
||||
try {
|
||||
const body = await context.clone().json() as { error?: unknown }
|
||||
if (typeof body.error === 'string') return body.error
|
||||
} catch {
|
||||
return 'purchase_verification_failed'
|
||||
}
|
||||
}
|
||||
const DEFAULT_VERIFICATION_ERROR = 'purchase_verification_failed'
|
||||
|
||||
/**
|
||||
* Billing Edge Functions put the machine code in `error`. Returns null when
|
||||
* the body carries no usable code so callers fall back to the Error message.
|
||||
*/
|
||||
function billingCodeFromBody(body: EdgeFunctionHttpBody): string | null {
|
||||
if (!body.readable || body.payload === null) return DEFAULT_VERIFICATION_ERROR
|
||||
const payload = body.payload
|
||||
if (typeof payload === 'object') {
|
||||
const code = (payload as { error?: unknown }).error
|
||||
if (typeof code === 'string') return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function errorMessageOrDefault(candidate: unknown): string {
|
||||
return candidate instanceof Error && candidate.message
|
||||
? candidate.message
|
||||
: 'purchase_verification_failed'
|
||||
: DEFAULT_VERIFICATION_ERROR
|
||||
}
|
||||
|
||||
function billingFailureCode(failure: EdgeFunctionFailure): string {
|
||||
const code = failure.body === null ? null : billingCodeFromBody(failure.body)
|
||||
return code ?? errorMessageOrDefault(failure.cause)
|
||||
}
|
||||
|
||||
async function functionErrorCode(candidate: unknown): Promise<string> {
|
||||
const http = await readEdgeFunctionHttpFailure(candidate)
|
||||
const code = http === null ? null : billingCodeFromBody(http.body)
|
||||
return code ?? errorMessageOrDefault(candidate)
|
||||
}
|
||||
|
||||
export function BillingProvider({ children }: { children: ReactNode }): React.ReactElement {
|
||||
|
|
@ -306,14 +327,13 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re
|
|||
setOperation(purchase.purchaseState === 'pending' ? 'pending' : 'verifying')
|
||||
setErrorCode(null)
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke<IapVerifyResult>('iap-verify', {
|
||||
body: {
|
||||
platform: 'google_play',
|
||||
productId: purchase.productId,
|
||||
purchaseToken: purchase.purchaseToken,
|
||||
},
|
||||
const result = await invokeEdgeFunction<IapVerifyResult>('iap-verify', {
|
||||
platform: 'google_play',
|
||||
productId: purchase.productId,
|
||||
purchaseToken: purchase.purchaseToken,
|
||||
})
|
||||
if (error) throw new Error(await functionErrorCode(error))
|
||||
if (!result.ok) throw new Error(billingFailureCode(result))
|
||||
const data = result.data
|
||||
const verifiedProduct = verifiedRestorableProduct(data, purchase.productId)
|
||||
const verification = data && isRecord(data.verification) ? data.verification : null
|
||||
const refreshed = await entitlement.refresh()
|
||||
|
|
@ -509,14 +529,13 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re
|
|||
restoredPurchases,
|
||||
async (restoredPurchase) => {
|
||||
if (userIdRef.current !== currentUserId) throw new Error('restore_session_changed')
|
||||
const { data, error } = await supabase.functions.invoke<IapVerifyResult>('iap-verify', {
|
||||
body: {
|
||||
platform: 'google_play',
|
||||
productId: restoredPurchase.productId,
|
||||
purchaseToken: restoredPurchase.purchaseToken,
|
||||
},
|
||||
const result = await invokeEdgeFunction<IapVerifyResult>('iap-verify', {
|
||||
platform: 'google_play',
|
||||
productId: restoredPurchase.productId,
|
||||
purchaseToken: restoredPurchase.purchaseToken,
|
||||
})
|
||||
if (error) throw new Error(await functionErrorCode(error))
|
||||
if (!result.ok) throw new Error(billingFailureCode(result))
|
||||
const data = result.data
|
||||
return data
|
||||
},
|
||||
async () => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue