Files
AskaEth 7512d593c2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
- 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`
2026-10-04 14:37:37 +08:00

327 lines
11 KiB
Go
Raw Permalink 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 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
}