- app_state.py: host_state_db_path() (host-level DB, distinct from per-VM state_db_path); kv_get/kv_put/kv_delete on meta (namespaced keys; kv_put skips the no-secrets guard by design — registry docs hold user prompts, documented); audit_trail table (P12 record shape + hash chain, seq explicit); counts()/backup() gain audit_trail. - swarm.py / presentation.py / browser_tasks.py: JSON-file backends replaced by KV rows (p8-swarm / p8-ledger / p8-browser-tasks); legacy JSON imported once on first load (renamed .migrated); corrupt row / unreadable store returns empty (fail-safe, same semantics as before; next successful save self-heals). - audit_trail.py: StoreAuditTrail (same record shape, hash chain, filters, export as JSONL version, backed by audit_trail table); migrate_jsonl_to_store() moves a chain verbatim (broken chain refused, divergent seq refused, re-run idempotent). JSONL class kept for reading old files. - P12 dict contracts now run against real P3/P5 code (ApprovalQueue.record -> build_approval_card -> card_matches_channel; CapabilityIssuer.issue -> describe -> PermissionRegistry.record_grant). - P6->P3 local protocol test: real PrivsepClient + real worker server + real git status; worker git argv carries the real P3 helper + useHttpPath; no token in argv/env/audit. runuser + HTTP pull stay real-machine-only (see gap docs). - Gap docs updated: P8 migration done, guest-side browser follow-up explicitly left for the real-machine track (no task_id in the browser protocol yet; lifecycle needs a real VM to verify); P6 wiring + P12 migration marked done; production-readiness.md gains §20 (round-12 integration). - Tests: new cloud/tests/test_chv_muse_round12.py, 24/24 OK; test_chv_muse_p8.py ledger corrupt test rewritten for the KV backend (+ legacy-import test). P7+P8: 95+1 tests OK.
642 lines
26 KiB
Python
642 lines
26 KiB
Python
"""每 VM 结构化应用状态库(对齐 Muse P7,叶子模块)。
|
||
|
||
Muse 官方安全文(security.muse.ai 2026-09-08,见
|
||
docs/chv-muse-p7-gap.md §1):Postgres 存持久应用状态,与运行时
|
||
单元、凭证存储分离。本轨的选型结论写在差距表 §4:轻量线用
|
||
SQLite(每 VM 一个 0600 文件,WAL),不是 Postgres——镜像
|
||
+150–250MB 与每 VM 一个 server 进程对 2GB 轻量 VM 太重;宿主侧
|
||
共享 Postgres 会把多用户状态集中到宿主,破坏每用户 VM 隔离。
|
||
|
||
与 Postgres 的差距(诚实写明):无多写者服务器、无网络访问、
|
||
无行级安全、无内置复制(复制 = 备份/恢复路径)。DDL 用两方
|
||
共通的受限子集(TEXT/INTEGER/REAL 列、TEXT id、无
|
||
AUTOINCREMENT、无 SQLite 专有语法、参数化查询),换 Postgres =
|
||
换连接工厂 + 同一份 DDL。
|
||
|
||
分离保证:
|
||
- 库文件只活在宿主 `state_dir/<vm>/app-state.sqlite`,不在客机
|
||
磁盘镜像里,nspawn cell 物理上够不着(测试钉住)。
|
||
- 凭证绝不进库:所有写操作过无凭证守卫(键名 + 值模式双重
|
||
检查);守卫拒绝时只报键名/模式,不含值。唯一例外是
|
||
round-12 新增的 ``kv_put``(P8 登记文档含用户 prompt,
|
||
prompt 里合法出现密钥样文本是调用方的事,旧 JSON 后端
|
||
同样不过守卫;见 kv_put 的 docstring)。
|
||
- audit 表只追加:本模块不提供任何删除/修改审计的方法。
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from collections.abc import Mapping
|
||
|
||
from cloud.chv.atomic_file import atomic_write, atomic_write_text
|
||
|
||
SCHEMA_VERSION = "1"
|
||
|
||
# DDL:SQLite 与 Postgres 共通的受限子集。TEXT id 由调用方生成
|
||
#(uuid hex),不用 AUTOINCREMENT;时间戳用 ISO 文本。
|
||
DDL = """
|
||
CREATE TABLE IF NOT EXISTS meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tasks (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
kind TEXT NOT NULL,
|
||
status TEXT NOT NULL,
|
||
payload_json TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_user ON tasks(user_id, status);
|
||
CREATE TABLE IF NOT EXISTS goals (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
plan_json TEXT NOT NULL,
|
||
status TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id, status);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
chat_id TEXT NOT NULL,
|
||
summary TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id, chat_id);
|
||
CREATE TABLE IF NOT EXISTS audit (
|
||
id TEXT PRIMARY KEY,
|
||
actor TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT NOT NULL,
|
||
detail_json TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit(target, created_at);
|
||
-- P12 统一审计轨迹(round-12 迁移目标):与 JSONL 版同一记录
|
||
-- 形状(seq/hash/prev_hash 链、project_id 隔离),seq 由写入方
|
||
-- 显式给(链是全局的,不按项目分链),不靠自增。
|
||
CREATE TABLE IF NOT EXISTS audit_trail (
|
||
seq INTEGER PRIMARY KEY,
|
||
project_id TEXT NOT NULL,
|
||
actor TEXT NOT NULL,
|
||
category TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
object_type TEXT NOT NULL,
|
||
object_id TEXT NOT NULL,
|
||
result TEXT NOT NULL,
|
||
reason TEXT NOT NULL,
|
||
task_id TEXT NOT NULL,
|
||
approval_id TEXT NOT NULL,
|
||
detail_json TEXT NOT NULL,
|
||
ts REAL NOT NULL,
|
||
prev_hash TEXT NOT NULL,
|
||
hash TEXT NOT NULL,
|
||
legacy INTEGER NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_audit_trail_project
|
||
ON audit_trail(project_id, ts, seq);
|
||
"""
|
||
|
||
_BACKUP_FILENAME = "app-state.sqlite"
|
||
_MANIFEST_FILENAME = "manifest.json"
|
||
|
||
# 无凭证守卫:键名含这些子串即拒;值匹配这些模式即拒。
|
||
_SECRET_KEY_SUBSTRINGS = ("TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY",
|
||
"API_KEY", "ACCESS_KEY", "CLIENT_SECRET")
|
||
_SECRET_VALUE_PATTERNS = (
|
||
re.compile(r"ghp_[A-Za-z0-9]{20,}"),
|
||
re.compile(r"gho_[A-Za-z0-9]{20,}"),
|
||
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
|
||
re.compile(r"sk-[A-Za-z0-9]{20,}"),
|
||
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
|
||
re.compile(r"AKIA[0-9A-Z]{16}"),
|
||
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||
)
|
||
|
||
|
||
class SecretRejectedError(ValueError):
|
||
"""写操作含疑似凭证:只报键名/模式,绝不含值。"""
|
||
|
||
|
||
class AppStateError(RuntimeError):
|
||
"""状态库操作失败(未打开、备份损坏等)。"""
|
||
|
||
|
||
def state_db_path(state_dir: str, vm_name: str) -> str:
|
||
"""某 VM 状态库路径:宿主 state_dir 下,不在客机磁盘镜像里。"""
|
||
return os.path.join(state_dir, vm_name, "app-state.sqlite")
|
||
|
||
|
||
def host_state_db_path(state_dir: str) -> str:
|
||
"""宿主级状态库路径(round-12):与 per-VM 库同库不同文件。
|
||
|
||
P8 的三处登记与 P12 的审计轨迹是宿主级状态(横跨多个 VM、
|
||
按 project_id 隔离),不属于任何一个 VM,所以不进
|
||
``state_db_path(state_dir, vm_name)`` 的 per-VM 库,另落在
|
||
state_dir 根下的 app-state.sqlite。同一个 AppStateStore 类、
|
||
同一套备份/恢复与 0600 权限口径,只是路径不同。"""
|
||
return os.path.join(state_dir, "app-state.sqlite")
|
||
|
||
|
||
def _utcnow() -> str:
|
||
"""ISO 时间戳(UTC,秒级)。"""
|
||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
|
||
|
||
def _new_id() -> str:
|
||
"""TEXT 主键:uuid hex。"""
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
def _scan_value(value: object, path: str) -> None:
|
||
"""递归扫一个值:键名与字符串值过无凭证守卫。"""
|
||
if isinstance(value, Mapping):
|
||
for key, item in value.items():
|
||
_check_key_name("%s.%s" % (path, key))
|
||
_scan_value(item, "%s.%s" % (path, key))
|
||
elif isinstance(value, (list, tuple)):
|
||
for item in value:
|
||
_scan_value(item, path)
|
||
elif isinstance(value, str):
|
||
_check_secret_value(value, path)
|
||
|
||
|
||
def _check_key_name(path: str) -> None:
|
||
"""键路径含凭证子串即拒(只报路径,不含值)。"""
|
||
upper = path.upper()
|
||
for sub in _SECRET_KEY_SUBSTRINGS:
|
||
if sub in upper:
|
||
raise SecretRejectedError(
|
||
"secret_key_name:%s contains %s" % (path, sub))
|
||
|
||
|
||
def _check_secret_value(value: str, path: str) -> None:
|
||
"""字符串值命中凭证模式即拒(只报路径与模式序号,不含值)。"""
|
||
for index, pattern in enumerate(_SECRET_VALUE_PATTERNS):
|
||
if pattern.search(value):
|
||
raise SecretRejectedError(
|
||
"secret_value_pattern:%s pattern#%d" % (path, index))
|
||
|
||
|
||
def assert_no_secrets(mapping: Mapping[str, object]) -> None:
|
||
"""断言一个映射可入库:键名与值都不过凭证守卫即拒。"""
|
||
_scan_value(dict(mapping), "payload")
|
||
|
||
|
||
def _sha256_file(path: str) -> str:
|
||
"""文件 sha256(流式读,不整文件进内存)。"""
|
||
digest = hashlib.sha256()
|
||
with open(path, "rb") as handle:
|
||
while True:
|
||
chunk = handle.read(65536)
|
||
if not chunk:
|
||
break
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def _read_manifest(snapshot_dir: str) -> dict[str, str]:
|
||
"""读并校验备份 manifest:文件在 + sha256 对得上才返回。"""
|
||
manifest_path = os.path.join(snapshot_dir, _MANIFEST_FILENAME)
|
||
if not os.path.isfile(manifest_path):
|
||
raise AppStateError("backup manifest missing: %s" % manifest_path)
|
||
with open(manifest_path, "r", encoding="utf-8") as handle:
|
||
manifest = json.load(handle)
|
||
backup_path = os.path.join(snapshot_dir, _BACKUP_FILENAME)
|
||
if not os.path.isfile(backup_path):
|
||
raise AppStateError("backup file missing: %s" % backup_path)
|
||
actual = _sha256_file(backup_path)
|
||
if actual != manifest.get("sha256"):
|
||
raise AppStateError("backup sha256 mismatch: %s" % backup_path)
|
||
return manifest
|
||
|
||
|
||
def verify_backup(snapshot_dir: str) -> dict[str, str]:
|
||
"""校验一次备份(manifest + sha256),返回 manifest。"""
|
||
return _read_manifest(snapshot_dir)
|
||
|
||
|
||
def restore_app_state(snapshot_dir: str, dest_path: str) -> dict[str, str]:
|
||
"""从备份恢复:先验 manifest,成功后先留 pre-restore 副本再替换。"""
|
||
manifest = _read_manifest(snapshot_dir)
|
||
backup_path = os.path.join(snapshot_dir, _BACKUP_FILENAME)
|
||
if os.path.exists(dest_path):
|
||
stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
|
||
preserved = "%s.pre-restore-%s" % (dest_path, stamp)
|
||
shutil.copyfile(dest_path, preserved)
|
||
os.chmod(preserved, 0o600)
|
||
os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
|
||
shutil.copyfile(backup_path, dest_path)
|
||
os.chmod(dest_path, 0o600)
|
||
return manifest
|
||
|
||
|
||
class AppStateStore:
|
||
"""每 VM 应用状态库:单写者(进程内锁 + SQLite busy 超时)。
|
||
|
||
用法:store = AppStateStore(path); store.open(); ...;
|
||
store.close()。也可用作上下文管理器。库文件 0600,目录 0700。
|
||
"""
|
||
|
||
def __init__(self, path: str) -> None:
|
||
self._path = path
|
||
self._conn: sqlite3.Connection | None = None
|
||
self._lock = threading.RLock()
|
||
|
||
def __enter__(self) -> "AppStateStore":
|
||
self.open()
|
||
return self
|
||
|
||
def __exit__(self, *exc: object) -> None:
|
||
self.close()
|
||
|
||
@property
|
||
def path(self) -> str:
|
||
"""库文件路径。"""
|
||
return self._path
|
||
|
||
def open(self) -> None:
|
||
"""打开(建库 + 建表 + 记 schema 版本),幂等。"""
|
||
with self._lock:
|
||
if self._conn is not None:
|
||
return
|
||
parent = os.path.dirname(self._path)
|
||
if parent:
|
||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||
conn = sqlite3.connect(self._path, timeout=30.0)
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA synchronous=FULL")
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
conn.executescript(DDL)
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
|
||
("schema_version", SCHEMA_VERSION))
|
||
conn.commit()
|
||
os.chmod(self._path, 0o600)
|
||
self._conn = conn
|
||
|
||
def close(self) -> None:
|
||
"""关闭连接(幂等)。"""
|
||
with self._lock:
|
||
if self._conn is None:
|
||
return
|
||
self._conn.close()
|
||
self._conn = None
|
||
|
||
def _require_conn(self) -> sqlite3.Connection:
|
||
"""取已打开的连接,未打开即报错。"""
|
||
if self._conn is None:
|
||
raise AppStateError("app state store not open: %s" % self._path)
|
||
return self._conn
|
||
|
||
def schema_version(self) -> str:
|
||
"""库的 schema 版本。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
row = conn.execute(
|
||
"SELECT value FROM meta WHERE key = ?",
|
||
("schema_version",)).fetchone()
|
||
return row[0] if row else ""
|
||
|
||
def kv_put(self, namespace: str, key: str, value: str) -> None:
|
||
"""写一条命名空间 KV(round-12:P8 三处登记的迁移后端)。
|
||
|
||
键存为 ``kv:<namespace>:<key>``,值原样存 TEXT(调用方
|
||
自己 JSON 序列化)。命名空间与键不得含 ``:``(分隔符),
|
||
不得为空。
|
||
|
||
注意:本方法**不过无凭证守卫**——P8 登记文档里含用户
|
||
写的子代理 prompt,prompt 里合法出现密钥样文本是调用
|
||
方的事(旧 JSON 文件后端同样不过守卫);守卫仍覆盖
|
||
put_task/put_goal/put_session/append_audit 这些写操作。
|
||
KV 的键是代码里写死的固定名,不含用户输入。"""
|
||
if not namespace or ":" in namespace \
|
||
or not isinstance(namespace, str):
|
||
raise ValueError("kv namespace must be a non-empty string"
|
||
" without ':'")
|
||
if not key or ":" in key or not isinstance(key, str):
|
||
raise ValueError("kv key must be a non-empty string"
|
||
" without ':'")
|
||
if not isinstance(value, str):
|
||
raise ValueError("kv value must be str")
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
|
||
("kv:%s:%s" % (namespace, key), value))
|
||
conn.commit()
|
||
|
||
def kv_get(self, namespace: str, key: str) -> str | None:
|
||
"""读一条命名空间 KV;不存在回 None。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
row = conn.execute(
|
||
"SELECT value FROM meta WHERE key = ?",
|
||
("kv:%s:%s" % (namespace, key),)).fetchone()
|
||
return row[0] if row else None
|
||
|
||
def kv_delete(self, namespace: str, key: str) -> None:
|
||
"""删一条命名空间 KV(不存在也静默成功)。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute("DELETE FROM meta WHERE key = ?",
|
||
("kv:%s:%s" % (namespace, key),))
|
||
conn.commit()
|
||
|
||
def insert_audit_trail_row(self, record: dict) -> None:
|
||
"""插一条 P12 审计轨迹行(round-12):原样存入,不重算
|
||
链——seq/hash/prev_hash 由调用方(StoreAuditTrail)算好,
|
||
迁移时也原样搬旧行。seq 重复即抛 sqlite3.IntegrityError。"""
|
||
detail = record.get("detail")
|
||
if not isinstance(detail, dict):
|
||
detail = {}
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute(
|
||
"INSERT INTO audit_trail(seq, project_id, actor,"
|
||
" category, action, object_type, object_id, result,"
|
||
" reason, task_id, approval_id, detail_json, ts,"
|
||
" prev_hash, hash, legacy) VALUES (?, ?, ?, ?, ?, ?,"
|
||
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(int(record["seq"]), str(record.get("project_id", "")),
|
||
str(record.get("actor", "")),
|
||
str(record.get("category", "")),
|
||
str(record.get("action", "")),
|
||
str(record.get("object_type", "")),
|
||
str(record.get("object_id", "")),
|
||
str(record.get("result", "ok")),
|
||
str(record.get("reason", "")),
|
||
str(record.get("task_id", "")),
|
||
str(record.get("approval_id", "")),
|
||
json.dumps(detail, sort_keys=True,
|
||
ensure_ascii=False),
|
||
float(record.get("ts", 0.0)),
|
||
str(record.get("prev_hash", "")),
|
||
str(record.get("hash", "")),
|
||
1 if record.get("legacy") else 0))
|
||
conn.commit()
|
||
|
||
def list_audit_trail_rows(self) -> list[dict]:
|
||
"""全部审计轨迹行,按 seq 升序(链顺序),与 JSONL 版
|
||
同款记录形状;legacy 只在为真时出现。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
rows = conn.execute(
|
||
"SELECT seq, project_id, actor, category, action,"
|
||
" object_type, object_id, result, reason, task_id,"
|
||
" approval_id, detail_json, ts, prev_hash, hash,"
|
||
" legacy FROM audit_trail ORDER BY seq ASC").fetchall()
|
||
out = []
|
||
for row in rows:
|
||
rec = {"seq": row[0], "ts": row[12],
|
||
"project_id": row[1], "actor": row[2],
|
||
"category": row[3], "action": row[4],
|
||
"object_type": row[5], "object_id": row[6],
|
||
"result": row[7], "reason": row[8],
|
||
"task_id": row[9], "approval_id": row[10],
|
||
"detail": json.loads(row[11]),
|
||
"prev_hash": row[13], "hash": row[14]}
|
||
if row[15]:
|
||
rec["legacy"] = True
|
||
out.append(rec)
|
||
return out
|
||
|
||
def counts(self) -> dict[str, int]:
|
||
"""各表行数(备份/恢复校验用)。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
out: dict[str, int] = {}
|
||
for table in ("tasks", "goals", "sessions", "audit",
|
||
"audit_trail"):
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) FROM %s" % table).fetchone()
|
||
out[table] = int(row[0])
|
||
return out
|
||
|
||
def put_task(self, user_id: str, task_id: str, kind: str,
|
||
status: str, payload: Mapping[str, object]) -> None:
|
||
"""写/覆盖一个任务(payload 过无凭证守卫)。"""
|
||
assert_no_secrets(payload)
|
||
now = _utcnow()
|
||
blob = json.dumps(dict(payload), sort_keys=True)
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute(
|
||
"INSERT INTO tasks(id, user_id, kind, status,"
|
||
" payload_json, created_at, updated_at)"
|
||
" VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||
" ON CONFLICT(id) DO UPDATE SET"
|
||
" status = excluded.status,"
|
||
" payload_json = excluded.payload_json,"
|
||
" updated_at = excluded.updated_at",
|
||
(task_id, user_id, kind, status, blob, now, now))
|
||
conn.commit()
|
||
|
||
def get_task(self, task_id: str) -> dict[str, object] | None:
|
||
"""取一个任务,不存在返回 None。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
row = conn.execute(
|
||
"SELECT id, user_id, kind, status, payload_json,"
|
||
" created_at, updated_at FROM tasks WHERE id = ?",
|
||
(task_id,)).fetchone()
|
||
return _row_to_task(row) if row else None
|
||
|
||
def list_tasks(self, user_id: str,
|
||
status: str | None = None) -> list[dict[str, object]]:
|
||
"""列某用户的任务(可按状态过滤,按创建时间升序)。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
if status is None:
|
||
rows = conn.execute(
|
||
"SELECT id, user_id, kind, status, payload_json,"
|
||
" created_at, updated_at FROM tasks"
|
||
" WHERE user_id = ? ORDER BY created_at",
|
||
(user_id,)).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"SELECT id, user_id, kind, status, payload_json,"
|
||
" created_at, updated_at FROM tasks"
|
||
" WHERE user_id = ? AND status = ? ORDER BY created_at",
|
||
(user_id, status)).fetchall()
|
||
return [_row_to_task(row) for row in rows]
|
||
|
||
def update_task_status(self, task_id: str, status: str) -> bool:
|
||
"""只改任务状态(不碰 payload),改到返回 True。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
cur = conn.execute(
|
||
"UPDATE tasks SET status = ?, updated_at = ?"
|
||
" WHERE id = ?",
|
||
(status, _utcnow(), task_id))
|
||
conn.commit()
|
||
return cur.rowcount == 1
|
||
|
||
def put_goal(self, user_id: str, goal_id: str, title: str,
|
||
plan: Mapping[str, object], status: str) -> None:
|
||
"""写/覆盖一个目标(plan 过无凭证守卫)。"""
|
||
assert_no_secrets(plan)
|
||
now = _utcnow()
|
||
blob = json.dumps(dict(plan), sort_keys=True)
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute(
|
||
"INSERT INTO goals(id, user_id, title, plan_json,"
|
||
" status, created_at, updated_at)"
|
||
" VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||
" ON CONFLICT(id) DO UPDATE SET"
|
||
" title = excluded.title,"
|
||
" plan_json = excluded.plan_json,"
|
||
" status = excluded.status,"
|
||
" updated_at = excluded.updated_at",
|
||
(goal_id, user_id, title, blob, status, now, now))
|
||
conn.commit()
|
||
|
||
def get_goal(self, goal_id: str) -> dict[str, object] | None:
|
||
"""取一个目标,不存在返回 None。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
row = conn.execute(
|
||
"SELECT id, user_id, title, plan_json, status,"
|
||
" created_at, updated_at FROM goals WHERE id = ?",
|
||
(goal_id,)).fetchone()
|
||
return _row_to_goal(row) if row else None
|
||
|
||
def list_goals(self, user_id: str) -> list[dict[str, object]]:
|
||
"""列某用户的目标(按创建时间升序)。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
rows = conn.execute(
|
||
"SELECT id, user_id, title, plan_json, status,"
|
||
" created_at, updated_at FROM goals"
|
||
" WHERE user_id = ? ORDER BY created_at",
|
||
(user_id,)).fetchall()
|
||
return [_row_to_goal(row) for row in rows]
|
||
|
||
def put_session(self, user_id: str, session_id: str, chat_id: str,
|
||
summary: str) -> None:
|
||
"""写/覆盖一个会话摘要(summary 过无凭证守卫)。"""
|
||
assert_no_secrets({"summary": summary})
|
||
now = _utcnow()
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute(
|
||
"INSERT INTO sessions(id, user_id, chat_id, summary,"
|
||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||
" ON CONFLICT(id) DO UPDATE SET"
|
||
" summary = excluded.summary,"
|
||
" updated_at = excluded.updated_at",
|
||
(session_id, user_id, chat_id, summary, now, now))
|
||
conn.commit()
|
||
|
||
def get_session(self, session_id: str) -> dict[str, object] | None:
|
||
"""取一个会话,不存在返回 None。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
row = conn.execute(
|
||
"SELECT id, user_id, chat_id, summary, created_at,"
|
||
" updated_at FROM sessions WHERE id = ?",
|
||
(session_id,)).fetchone()
|
||
if row is None:
|
||
return None
|
||
return {"id": row[0], "user_id": row[1], "chat_id": row[2],
|
||
"summary": row[3], "created_at": row[4],
|
||
"updated_at": row[5]}
|
||
|
||
def append_audit(self, actor: str, action: str, target: str,
|
||
detail: Mapping[str, object]) -> str:
|
||
"""追加一条审计记录(只追加,无删除/修改接口)。"""
|
||
assert_no_secrets(detail)
|
||
record_id = _new_id()
|
||
blob = json.dumps(dict(detail), sort_keys=True)
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
conn.execute(
|
||
"INSERT INTO audit(id, actor, action, target,"
|
||
" detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||
(record_id, actor, action, target, blob, _utcnow()))
|
||
conn.commit()
|
||
return record_id
|
||
|
||
def list_audit(self, target: str | None = None,
|
||
limit: int = 100) -> list[dict[str, object]]:
|
||
"""列审计记录(最新在前;target 可过滤)。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
if target is None:
|
||
rows = conn.execute(
|
||
"SELECT id, actor, action, target, detail_json,"
|
||
" created_at FROM audit ORDER BY created_at DESC"
|
||
" LIMIT ?", (limit,)).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"SELECT id, actor, action, target, detail_json,"
|
||
" created_at FROM audit WHERE target = ?"
|
||
" ORDER BY created_at DESC LIMIT ?",
|
||
(target, limit)).fetchall()
|
||
return [_row_to_audit(row) for row in rows]
|
||
|
||
def backup(self, dest_dir: str) -> dict[str, str]:
|
||
"""在线备份到目录:库文件 + manifest(sha256 + 行数 + 版本)。"""
|
||
with self._lock:
|
||
conn = self._require_conn()
|
||
counts = self.counts()
|
||
os.makedirs(dest_dir, mode=0o700, exist_ok=True)
|
||
backup_path = os.path.join(dest_dir, _BACKUP_FILENAME)
|
||
if os.path.exists(backup_path):
|
||
os.remove(backup_path)
|
||
target = sqlite3.connect(backup_path)
|
||
try:
|
||
conn.backup(target)
|
||
finally:
|
||
target.close()
|
||
os.chmod(backup_path, 0o600)
|
||
manifest = {
|
||
"schema_version": SCHEMA_VERSION,
|
||
"sha256": _sha256_file(backup_path),
|
||
"tasks": str(counts["tasks"]),
|
||
"goals": str(counts["goals"]),
|
||
"sessions": str(counts["sessions"]),
|
||
"audit": str(counts["audit"]),
|
||
"audit_trail": str(counts["audit_trail"]),
|
||
"created_at": _utcnow(),
|
||
}
|
||
atomic_write_text(
|
||
os.path.join(dest_dir, _MANIFEST_FILENAME),
|
||
json.dumps(manifest, sort_keys=True, indent=2) + "\n")
|
||
return manifest
|
||
|
||
|
||
def _row_to_task(row: tuple) -> dict[str, object]:
|
||
"""tasks 行 → dict(payload_json 解析回 dict)。"""
|
||
return {"id": row[0], "user_id": row[1], "kind": row[2],
|
||
"status": row[3],
|
||
"payload": json.loads(row[4]),
|
||
"created_at": row[5], "updated_at": row[6]}
|
||
|
||
|
||
def _row_to_goal(row: tuple) -> dict[str, object]:
|
||
"""goals 行 → dict(plan_json 解析回 dict)。"""
|
||
return {"id": row[0], "user_id": row[1], "title": row[2],
|
||
"plan": json.loads(row[3]), "status": row[4],
|
||
"created_at": row[5], "updated_at": row[6]}
|
||
|
||
|
||
def _row_to_audit(row: tuple) -> dict[str, object]:
|
||
"""audit 行 → dict(detail_json 解析回 dict)。"""
|
||
return {"id": row[0], "actor": row[1], "action": row[2],
|
||
"target": row[3], "detail": json.loads(row[4]),
|
||
"created_at": row[5]}
|