feat(client): add durable HTTP task state

This commit is contained in:
QiuSW
2026-08-05 00:59:32 +08:00
parent 488005ac93
commit 31f07ac245
37 changed files with 4654 additions and 20 deletions
+62 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True)
@@ -17,21 +18,34 @@ class RuntimePaths:
root: Path
logs: Path
artifacts: Path
state: Path
database: Path
@classmethod
def from_root(cls, root: Path) -> "RuntimePaths":
resolved_root = root.expanduser()
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
resolved_root = root.expanduser().resolve(strict=False)
state = resolved_root / "state"
return cls(
root=resolved_root,
logs=resolved_root / "logs",
artifacts=resolved_root / "artifacts",
state=state,
database=state / "client-state.sqlite3",
)
@classmethod
def default(cls) -> "RuntimePaths":
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
return cls.from_root(Path(local_app_data) / "cmbuyer")
local_root = Path(local_app_data).expanduser()
if not local_root.is_absolute():
raise RuntimeError("local_app_data_must_be_absolute")
return cls.from_root(local_root / "cmbuyer")
if os.name == "nt":
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
raise RuntimeError("local_app_data_required")
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
@@ -40,3 +54,49 @@ class RuntimePaths:
self.logs.mkdir(parents=True, exist_ok=True)
self.artifacts.mkdir(parents=True, exist_ok=True)
self.state.mkdir(parents=True, exist_ok=True)
@dataclass
class LocalStateRuntime:
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
paths: RuntimePaths
mutex: Any
store: Any
@classmethod
def open(
cls,
paths: RuntimePaths | None = None,
*,
mutex_factory: Callable[[Path], Any] | None = None,
protector_factory: Callable[[], Any] | None = None,
store_factory: Callable[[Path, Any], Any] | None = None,
) -> "LocalStateRuntime":
from .localstate.protection import DpapiProtector
from .localstate.single_instance import NamedMutex
from .localstate.store import LocalStateStore
selected = paths or RuntimePaths.default()
selected.ensure_exists()
make_mutex = mutex_factory or NamedMutex
make_protector = protector_factory or DpapiProtector
make_store = store_factory or LocalStateStore
mutex = make_mutex(selected.database)
try:
protector = make_protector()
store = make_store(selected.database, protector)
except Exception:
mutex.close()
raise
return cls(selected, mutex, store)
def close(self) -> None:
self.mutex.close()
def __enter__(self) -> "LocalStateRuntime":
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()