Files
HomeAgent/internal/memory/graph_readonly_test.go
JianFeeeee 818ce2698f feat(memory): N2a 第一块砖 —— 主图记忆的受限句柄(query_only),子是"读得到写不进"
按用户确认的形态:**独立存储实例**(不是给共享记忆层加 space 列)。

## 结构性保证

新增 `OpenGraphDBReadOnly(path)`:以**受限句柄**打开图库 ——
连接保持正常打开能力(可读、可恢复 WAL),但 `PRAGMA query_only=1` 让
任何 INSERT/UPDATE/DELETE 被 SQLite **直接拒绝**。

为什么不用 DSN 的 `mode=ro`:只读连接在 WAL 库上无法自行恢复 -wal,
而主库在父 agent 手里是持续写入的。query_only 只堵写、不堵读,语义正好。

⇒ "子改不了主记忆"是**结构性**的,不靠调用方自觉;也不建表、不迁移
(库由父建好,受限句柄不会凭空造出一个空主库)。

## 设计文档

新增 §5.6「实现形态:独立存储实例(不做 space 列)」,写明轻量内核的记忆装配:

    子的轻量内核
    ├─ temp 图记忆实例(独立存储,读写)  ← 与子同生共死
    └─ 主图记忆的受限句柄(只读)
    子的查询 = 两个实例各查一次 + 应用层合并(并集)
    回收时由父读 temp、选记录、写进主图记忆

并记录用户给的备选简化:`AllowTempGraphWrite` 开关(默认开);设为 false 时
子对图记忆完全只读,没有 temp 实例、没有合入。

## 验收

`internal/memory/graph_readonly_test.go`(2 项):
- 受限句柄读得到、写被拒(Commit/Purge 双双报错),且**主库不留痕迹**
- 库不存在时受限句柄的查询报错,而不是凭空建表后返回空结果

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:32:29 +08:00

82 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
// N2a 第一块砖:**受限句柄**query_only—— 子是"读得到主记忆、写不进去"的结构性保证。
//
// 设计docs/zh/resident-subagent-design.md §5两级空间与 §5.5(子的记忆面)。
// 轻量内核拿的是「主库受限句柄(只读)+ 自己的 temp 实例(读写)」,
// 于是"子改不了 main"不靠调用方自觉,而是被 SQLite 直接拒。
import (
"path/filepath"
"testing"
)
func TestOpenGraphDBReadOnly_ReadsWorkWritesRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "main.db")
// 父 agent 先建库并写入数据。
main, err := NewGraphDB(path)
if err != nil {
t.Fatal(err)
}
if _, _, err := main.Commit([]Triple{{
Subject: "张三", Relation: "任职于", Object: "某公司",
}}, "sess-1", 1); err != nil {
t.Fatalf("父写入失败: %v", err)
}
// 子拿到受限句柄。
ro, err := OpenGraphDBReadOnly(path)
if err != nil {
t.Fatalf("打开受限句柄失败: %v", err)
}
defer ro.Close()
// ① 读得到。
res, err := ro.Recall([]string{"张三"}, nil, 1, "")
if err != nil {
t.Fatalf("受限句柄应能读: %v", err)
}
if res == nil {
t.Fatal("读取结果不应为 nil")
}
// ② 写不进去 —— 结构性拒绝(不是"约定不写")。
if _, _, err := ro.Commit([]Triple{{
Subject: "李四", Relation: "任职于", Object: "另一公司",
}}, "sess-2", 1); err == nil {
t.Fatal("受限句柄的写入必须被 SQLite 拒绝query_only")
}
if _, err := ro.Purge(map[string]string{"subject": "张三"}, "soft"); err == nil {
t.Fatal("受限句柄的 Purge 必须被拒")
}
// ③ 父库不受影响:子写失败没留下任何痕迹。
again, err := main.Recall([]string{"李四"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if again != nil && len(again.Entities) > 0 {
t.Fatalf("子写失败却在主库留下了痕迹:%+v", again.Entities)
}
}
// 受限句柄不建表、不迁移:库不存在时按只读语义处理(查不到东西),
// 但**不得**悄悄创建出一个空库(否则"子"会凭空造出主库)。
func TestOpenGraphDBReadOnly_DoesNotCreateSchema(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.db")
ro, err := OpenGraphDBReadOnly(path)
if err != nil {
// 打开本身允许失败(某些平台会因文件不存在直接报错)——
// 关键是"不得建表成功"。
return
}
defer ro.Close()
if _, err := ro.Recall([]string{"任意"}, nil, 1, ""); err == nil {
t.Fatal("库不存在时受限句柄的查询应当报错,而不是凭空建表后返回空结果")
}
}