- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
`clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
(0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
327 lines
11 KiB
Go
327 lines
11 KiB
Go
package registry
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
)
|
||
|
||
// 本文件锁定裁决 1.3/10.4.2(工具策略落盘):迁移 0012_tool_policy 的 up/down
|
||
// 可逆、以及「重启后策略仍在」——后者用「关连接、重建注册表、再 Load」模拟
|
||
// 一次真实重启。
|
||
|
||
// errStoreDown 是内存桩注入的落盘失败。
|
||
var errStoreDown = errors.New("policy store down")
|
||
|
||
// testStoreLogger 返回一个静默 logger:存储层测试不关心日志输出。
|
||
func testStoreLogger() *slog.Logger {
|
||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|
||
|
||
// openMigrated 开一个(文件或 :memory:)库并跑完全部迁移。
|
||
// 用文件而非 :memory: 的用例是因为「重启仍在」要跨连接读同一份数据。
|
||
func openMigrated(t *testing.T, path string) *sql.DB {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
conn, err := db.Open(ctx, path, testStoreLogger())
|
||
if err != nil {
|
||
t.Fatalf("db.Open(%s): %v", path, err)
|
||
}
|
||
if err := db.MigrateUp(ctx, conn, testStoreLogger()); err != nil {
|
||
conn.Close()
|
||
t.Fatalf("MigrateUp: %v", err)
|
||
}
|
||
t.Cleanup(func() { conn.Close() })
|
||
return conn
|
||
}
|
||
|
||
// toolPoliciesTableExists 报告 tool_policies 表是否存在。
|
||
func toolPoliciesTableExists(t *testing.T, ctx context.Context, conn *sql.DB) bool {
|
||
t.Helper()
|
||
var name string
|
||
err := conn.QueryRowContext(ctx,
|
||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tool_policies'`).Scan(&name)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return false
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("query sqlite_master: %v", err)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// copyFile 复制一个文件(副本迁移验证用)。
|
||
func copyFile(t *testing.T, src, dst string) {
|
||
t.Helper()
|
||
data, err := os.ReadFile(src)
|
||
if err != nil {
|
||
t.Fatalf("read %s: %v", src, err)
|
||
}
|
||
if err := os.WriteFile(dst, data, 0o600); err != nil {
|
||
t.Fatalf("write %s: %v", dst, err)
|
||
}
|
||
}
|
||
|
||
// TestToolPolicyMigration_UpDownUpOnDataDirCopy 在 data/cyrene.db 的**副本**上
|
||
// 跑迁移 0012 的 up → down → up,证明可逆且幂等(绝不碰本体)。
|
||
// 副本不存在时跳过(例如干净检出的 CI),此时 :memory: 的用例仍然覆盖同一迁移。
|
||
func TestToolPolicyMigration_UpDownUpOnDataDirCopy(t *testing.T) {
|
||
ctx := context.Background()
|
||
src := filepath.Join("..", "..", "..", "data", "cyrene.db")
|
||
if _, err := os.Stat(src); err != nil {
|
||
t.Skipf("data/cyrene.db 不可读,跳过副本迁移验证: %v", err)
|
||
}
|
||
dst := filepath.Join(t.TempDir(), "cyrene-copy.db")
|
||
copyFile(t, src, dst)
|
||
// WAL 模式下已提交内容可能还在 -wal 里,一并带过去,副本才是完整快照。
|
||
for _, suffix := range []string{"-wal", "-shm"} {
|
||
if _, err := os.Stat(src + suffix); err == nil {
|
||
copyFile(t, src+suffix, dst+suffix)
|
||
}
|
||
}
|
||
|
||
conn, err := db.Open(ctx, dst, testStoreLogger())
|
||
if err != nil {
|
||
t.Fatalf("db.Open(copy): %v", err)
|
||
}
|
||
defer conn.Close()
|
||
|
||
if err := db.MigrateUp(ctx, conn, testStoreLogger()); err != nil {
|
||
t.Fatalf("MigrateUp(copy): %v", err)
|
||
}
|
||
if !toolPoliciesTableExists(t, ctx, conn) {
|
||
t.Fatal("tool_policies 应在 MigrateUp 后存在")
|
||
}
|
||
if _, err := conn.ExecContext(ctx,
|
||
`INSERT INTO tool_policies (name, allow_in_group) VALUES ('copied_tool', 1)`); err != nil {
|
||
t.Fatalf("insert into copy: %v", err)
|
||
}
|
||
|
||
// down:回滚到 0012 之前 —— 按已应用的迁移现算步数,而不是写死「最后一步」。
|
||
// 写死会让后续任何新迁移(0013 起)悄悄变成「回滚那个新迁移」,于是本测试再也
|
||
// 测不到 0012 的 down(0013 落地时正是这样暴露的)。
|
||
steps := migrationStepsPast(t, ctx, conn, "0012_tool_policy")
|
||
if err := db.MigrateDown(ctx, conn, steps, testStoreLogger()); err != nil {
|
||
t.Fatalf("MigrateDown(copy, %d): %v", steps, err)
|
||
}
|
||
if toolPoliciesTableExists(t, ctx, conn) {
|
||
t.Fatal("tool_policies 应在 MigrateDown 后消失")
|
||
}
|
||
|
||
// 再 up:可逆且幂等。
|
||
if err := db.MigrateUp(ctx, conn, testStoreLogger()); err != nil {
|
||
t.Fatalf("MigrateUp(copy, 2nd): %v", err)
|
||
}
|
||
if !toolPoliciesTableExists(t, ctx, conn) {
|
||
t.Fatal("tool_policies 应在第二次 MigrateUp 后重新出现")
|
||
}
|
||
var rows int
|
||
if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM tool_policies`).Scan(&rows); err != nil {
|
||
t.Fatalf("count rows: %v", err)
|
||
}
|
||
if rows != 0 {
|
||
t.Fatalf("回滚再升级后应有 0 行,得到 %d 行(回滚未清空?)", rows)
|
||
}
|
||
}
|
||
|
||
// TestToolPolicyMigration_UpDownOnMemory 在干净库上锁定同一迁移的 up/down
|
||
// (不依赖 data/ 目录是否存在)。
|
||
func TestToolPolicyMigration_UpDownOnMemory(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
conn := openMigrated(t, ":memory:")
|
||
|
||
if !toolPoliciesTableExists(t, ctx, conn) {
|
||
t.Fatal("tool_policies 应在 MigrateUp 后存在")
|
||
}
|
||
steps := migrationStepsPast(t, ctx, conn, "0012_tool_policy")
|
||
if err := db.MigrateDown(ctx, conn, steps, testStoreLogger()); err != nil {
|
||
t.Fatalf("MigrateDown(%d): %v", steps, err)
|
||
}
|
||
if toolPoliciesTableExists(t, ctx, conn) {
|
||
t.Fatal("tool_policies 应在 MigrateDown 后消失")
|
||
}
|
||
}
|
||
|
||
// migrationStepsPast 计算「回滚到 id 之前」需要走几步:MigrateDown 从最新往回
|
||
// 走,因此 id 自己以及比它新的每一个各算一步。按 schema_migrations 现算,本测试
|
||
// 因此只锁定 0012 的 down,不会被之后新增的迁移(0013 及以后)带偏。
|
||
func migrationStepsPast(t *testing.T, ctx context.Context, conn *sql.DB, id string) int {
|
||
t.Helper()
|
||
rows, err := conn.QueryContext(ctx, `SELECT id FROM schema_migrations`)
|
||
if err != nil {
|
||
t.Fatalf("列出已应用迁移:%v", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
steps := 0
|
||
for rows.Next() {
|
||
var got string
|
||
if err := rows.Scan(&got); err != nil {
|
||
t.Fatalf("读取迁移 id:%v", err)
|
||
}
|
||
if got >= id {
|
||
steps++
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("遍历迁移:%v", err)
|
||
}
|
||
if steps == 0 {
|
||
t.Fatalf("已应用迁移里没有 %s,本测试的前提不成立", id)
|
||
}
|
||
return steps
|
||
}
|
||
|
||
// TestRegistry_PolicyStoreSurvivesRestart 锁定「重启后策略仍在」:
|
||
// 第一次运行 Save,第二次运行(新连接 + 新注册表 + 新 store)Load 回来。
|
||
func TestRegistry_PolicyStoreSurvivesRestart(t *testing.T) {
|
||
ctx := context.Background()
|
||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||
|
||
first := openMigrated(t, path)
|
||
store, err := NewSQLitePolicyStore(first, testStoreLogger())
|
||
if err != nil {
|
||
t.Fatalf("NewSQLitePolicyStore: %v", err)
|
||
}
|
||
reg := New()
|
||
if err := reg.AttachPolicyStore(ctx, store); err != nil {
|
||
t.Fatalf("AttachPolicyStore: %v", err)
|
||
}
|
||
for _, name := range []string{"group_tool", "other"} {
|
||
if err := reg.RegisterTool(regTool{name: name}); err != nil {
|
||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||
}
|
||
}
|
||
// 初始值必须是默认(两个布尔 false = owner-only)。
|
||
if pol, err := reg.Policy("group_tool"); err != nil || pol.AllowInGroup || pol.AllowInStranger {
|
||
t.Fatalf("initial policy = %+v (err %v), want both false", pol, err)
|
||
}
|
||
if err := reg.SetPolicy("group_tool", ToolPolicy{AllowInGroup: true}); err != nil {
|
||
t.Fatalf("SetPolicy: %v", err)
|
||
}
|
||
if err := first.Close(); err != nil { // 模拟进程退出
|
||
t.Fatalf("close first connection: %v", err)
|
||
}
|
||
|
||
second := openMigrated(t, path)
|
||
store2, err := NewSQLitePolicyStore(second, testStoreLogger())
|
||
if err != nil {
|
||
t.Fatalf("NewSQLitePolicyStore(2): %v", err)
|
||
}
|
||
reg2 := New()
|
||
if err := reg2.AttachPolicyStore(ctx, store2); err != nil {
|
||
t.Fatalf("AttachPolicyStore(2): %v", err)
|
||
}
|
||
// 重启后注册表里还没有能力,只有 Load 出的策略快照……
|
||
if _, err := reg2.Policy("group_tool"); !errors.Is(err, ErrNotFound) {
|
||
t.Fatalf("Policy before re-register error = %v, want ErrNotFound", err)
|
||
}
|
||
// ……能力注册进来的那一刻套上落盘值(MCP 热装等晚注册路径同理)。
|
||
if err := reg2.RegisterTool(regTool{name: "group_tool"}); err != nil {
|
||
t.Fatalf("RegisterTool(2): %v", err)
|
||
}
|
||
pol, err := reg2.Policy("group_tool")
|
||
if err != nil {
|
||
t.Fatalf("Policy(2): %v", err)
|
||
}
|
||
if !pol.AllowInGroup || pol.AllowInStranger {
|
||
t.Fatalf("policy after restart = %+v, want {AllowInGroup:true AllowInStranger:false}", pol)
|
||
}
|
||
// 没配过的能力仍是默认(tool_policies 里没有它的行)。
|
||
if err := reg2.RegisterTool(regTool{name: "other"}); err != nil {
|
||
t.Fatalf("RegisterTool(other): %v", err)
|
||
}
|
||
if pol, err := reg2.Policy("other"); err != nil || pol.AllowInGroup || pol.AllowInStranger {
|
||
t.Fatalf("unconfigured policy = %+v (err %v), want both false", pol, err)
|
||
}
|
||
}
|
||
|
||
// TestRegistry_AttachPolicyStoreAppliesToRegisteredCaps 锁定反向顺序:
|
||
// 能力先注册、策略后 Load(AttachPolicyStore 在 wireRuntime 才启用的场景),
|
||
// 已注册能力也必须立刻拿到落盘值。
|
||
func TestRegistry_AttachPolicyStoreAppliesToRegisteredCaps(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
seed := newMemoryPolicyStore()
|
||
seed.marks["stranger_tool"] = ToolPolicy{AllowInStranger: true}
|
||
|
||
reg := New()
|
||
if err := reg.RegisterTool(regTool{name: "stranger_tool"}); err != nil {
|
||
t.Fatalf("RegisterTool: %v", err)
|
||
}
|
||
if err := reg.AttachPolicyStore(ctx, seed); err != nil {
|
||
t.Fatalf("AttachPolicyStore: %v", err)
|
||
}
|
||
pol, err := reg.Policy("stranger_tool")
|
||
if err != nil {
|
||
t.Fatalf("Policy: %v", err)
|
||
}
|
||
if pol.AllowInGroup || !pol.AllowInStranger {
|
||
t.Fatalf("policy after attach = %+v, want {AllowInGroup:false AllowInStranger:true}", pol)
|
||
}
|
||
}
|
||
|
||
// TestRegistry_SetPolicyPersistFailureKeepsMemory 锁定「先落库、后改内存」:
|
||
// 落盘失败时内存保持旧值且返回错误,绝不出现「面板说成功、重启却复位」。
|
||
func TestRegistry_SetPolicyPersistFailureKeepsMemory(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newMemoryPolicyStore()
|
||
reg := New()
|
||
if err := reg.RegisterTool(regTool{name: "calc"}); err != nil {
|
||
t.Fatalf("RegisterTool: %v", err)
|
||
}
|
||
if err := reg.AttachPolicyStore(ctx, store); err != nil {
|
||
t.Fatalf("AttachPolicyStore: %v", err)
|
||
}
|
||
store.failSave = true
|
||
if err := reg.SetPolicy("calc", ToolPolicy{AllowInGroup: true}); err == nil {
|
||
t.Fatal("SetPolicy with failing store = nil error, want error")
|
||
}
|
||
pol, err := reg.Policy("calc")
|
||
if err != nil {
|
||
t.Fatalf("Policy: %v", err)
|
||
}
|
||
if pol.AllowInGroup || pol.AllowInStranger {
|
||
t.Fatalf("policy after failed persist = %+v, want unchanged (both false)", pol)
|
||
}
|
||
// 单布尔入口(SetAllowInGroup/SetAllowInStranger)走同一条落库路径。
|
||
if err := reg.SetAllowInGroup("calc", true); err == nil {
|
||
t.Fatal("SetAllowInGroup with failing store = nil error, want error")
|
||
}
|
||
}
|
||
|
||
// memoryPolicyStore 是 PolicyStore 的内存桩:Load/Save 语义与 SQLite 实现一致
|
||
// (只保存写过的能力),failSave 用于注入落盘失败。
|
||
type memoryPolicyStore struct {
|
||
marks map[string]ToolPolicy
|
||
failSave bool
|
||
}
|
||
|
||
func newMemoryPolicyStore() *memoryPolicyStore {
|
||
return &memoryPolicyStore{marks: make(map[string]ToolPolicy)}
|
||
}
|
||
|
||
func (m *memoryPolicyStore) Load(_ context.Context) (map[string]ToolPolicy, error) {
|
||
out := make(map[string]ToolPolicy, len(m.marks))
|
||
for k, v := range m.marks {
|
||
out[k] = v
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (m *memoryPolicyStore) Save(_ context.Context, name string, p ToolPolicy) error {
|
||
if m.failSave {
|
||
return errStoreDown
|
||
}
|
||
m.marks[name] = p
|
||
return nil
|
||
}
|