From e046bbd15cbf5f99113ab8ec0bbffe580e9d2428 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 03:48:13 +0000 Subject: [PATCH 1/3] Add agent_system_identity marker with ensure/verify CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: U1 I3 needs a DB-side instance identity anchor matching manifest.instance_id so wrong/missing DB blocks startup (I4). Evidence: go test ./internal/store/ ./internal/app/; CLI smoke ensure/verify on SQLite temp dir. Impact: migration v26; store Ensure/Verify; identity subcommand; serve checks AGENT_SYSTEM_INSTANCE_ID when set. Refs: docs/recovery/u1-identity-manifest.md §5.2; Dev feature/recovery-u1-db-identity Co-authored-by: Cursor --- cmd/agent-system/main.go | 49 ++++++++++ internal/app/app.go | 29 ++++++ internal/store/migrate.go | 24 +++++ internal/store/system_identity.go | 118 +++++++++++++++++++++++++ internal/store/system_identity_test.go | 70 +++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 internal/store/system_identity.go create mode 100644 internal/store/system_identity_test.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 4b4a03b..ede0b45 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -32,6 +32,8 @@ func main() { os.Exit(runConfig(os.Args[2:])) case "user": os.Exit(runUser(os.Args[2:])) + case "identity": + os.Exit(runIdentity(os.Args[2:])) } } fs := flag.NewFlagSet("serve", flag.ExitOnError) @@ -343,3 +345,50 @@ func runUser(args []string) int { return 2 } } + +// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。 +// 与 serve --instance(runs 进程标识)无关。 +func runIdentity(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法:agent-system identity --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]") + return 2 + } + sub := args[0] + fs := flag.NewFlagSet("identity "+sub, flag.ExitOnError) + data := fs.String("data", "./data", "数据目录(SQLite 路径 /agent-system.db)") + dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite)") + instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc--…)") + dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)") + fs.Parse(args[1:]) + if *instanceID == "" { + fmt.Fprintln(os.Stderr, "identity: --instance-id 必填") + return 2 + } + ref := *dbref + if ref == "" { + if *dsn != "" { + ref = "pg" + } else { + ref = "sqlite" + } + } + switch sub { + case "ensure": + if err := app.EnsureSystemIdentity(*dsn, *data, *instanceID, ref); err != nil { + fmt.Fprintln(os.Stderr, "identity ensure:", err) + return 1 + } + fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref) + return 0 + case "verify": + if err := app.VerifySystemIdentity(*dsn, *data, *instanceID, ref); err != nil { + fmt.Fprintln(os.Stderr, "identity verify:", err) + return 1 + } + fmt.Printf("verified instance_id=%s dbref=%s\n", *instanceID, ref) + return 0 + default: + fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub) + return 2 + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 3e49b15..630ccc3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -199,6 +199,21 @@ func Run(opt Options) error { return fmt.Errorf("open store: %w", err) } defer st.Close() + // U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐; + // 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。 + if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" { + dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF")) + if dbref == "" { + if opt.StorageDSN != "" { + dbref = "pg" + } else { + dbref = "sqlite" + } + } + if err := st.VerifySystemIdentity(sysID, dbref); err != nil { + return fmt.Errorf("system identity: %w", err) + } + } pool := st.Pool() if versions, verr := st.MigrationStatus(); verr == nil { log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)", @@ -894,6 +909,20 @@ func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) { return st.RevokeAPIKeyByName(owner, name) } +// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。 +func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error { + return withStore(storageDSN, dataDir, func(st *store.Store) error { + return st.EnsureSystemIdentity(instanceID, dbref) + }) +} + +// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。 +func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error { + return withStore(storageDSN, dataDir, func(st *store.Store) error { + return st.VerifySystemIdentity(instanceID, dbref) + }) +} + // cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。 func cachedInt(ttl time.Duration, fn func() int) func() int { var mu sync.Mutex diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 3656ab5..6dbac23 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -754,6 +754,30 @@ func init() { `DROP INDEX IF EXISTS idx_login_keys_owner_purpose`, `DROP TABLE IF EXISTS login_keys`, }}}, + // U1 I3:服务实例 DB 身份 marker(设计 docs/recovery/u1-identity-manifest.md §5.2)。 + // 全表至多一行由 Ensure/Verify 强制;与 runs.instance_id(进程标识)无关。 + // v26 已被 login_keys 占用,本表为 v27。 + migration{version: 27, name: "agent_system_identity", + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS agent_system_identity ( + instance_id TEXT NOT NULL PRIMARY KEY, + dbref TEXT NOT NULL, + schema_version INTEGER NOT NULL, + created_at TEXT NOT NULL + )`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS agent_system_identity ( + instance_id TEXT NOT NULL PRIMARY KEY, + dbref TEXT NOT NULL, + schema_version INTEGER NOT NULL, + created_at TEXT NOT NULL + )`, + }}, + down: map[string][]string{"sqlite": { + `DROP TABLE IF EXISTS agent_system_identity`, + }, "postgres": { + `DROP TABLE IF EXISTS agent_system_identity`, + }}}, ) } diff --git a/internal/store/system_identity.go b/internal/store/system_identity.go new file mode 100644 index 0000000..0aeaaa5 --- /dev/null +++ b/internal/store/system_identity.go @@ -0,0 +1,118 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +// ErrSystemIdentityMismatch 库内身份与期望不符(错库/错实例/多行)——启动必须失败。 +var ErrSystemIdentityMismatch = errors.New("system identity mismatch") + +// ErrSystemIdentityMissing 表中无身份行——须先 Ensure(svc init / 首次建立),禁止静默插入。 +var ErrSystemIdentityMissing = errors.New("system identity missing") + +// SystemIdentity 是 agent_system_identity 表的一行(一库一服务身份)。 +type SystemIdentity struct { + InstanceID string + DBRef string + SchemaVersion int + CreatedAt string +} + +// EnsureSystemIdentity 仅用于显式建立路径:表空则插入;已存在且一致则幂等; +// 不一致或多余行 fail-closed。绝不在常规 start 路径调用以「补写」掩盖漂移。 +func (s *Store) EnsureSystemIdentity(instanceID, dbref string) error { + if instanceID == "" { + return fmt.Errorf("EnsureSystemIdentity: instance_id 为空") + } + if dbref == "" { + dbref = "sqlite" + } + row, n, err := s.readSystemIdentity() + if err != nil { + return err + } + if n > 1 { + return fmt.Errorf("%w: agent_system_identity 有 %d 行(期望至多 1)", ErrSystemIdentityMismatch, n) + } + if n == 1 { + if row.InstanceID != instanceID || row.DBRef != dbref { + return fmt.Errorf("%w: 库内 instance_id=%q dbref=%q ≠ 期望 %q / %q", + ErrSystemIdentityMismatch, row.InstanceID, row.DBRef, instanceID, dbref) + } + return nil + } + ver := latestMigrationVersion() + _, err = s.db.Exec(s.q(`INSERT INTO agent_system_identity(instance_id, dbref, schema_version, created_at) VALUES(?,?,?,?)`), + instanceID, dbref, ver, nowISO()) + if err != nil { + return fmt.Errorf("EnsureSystemIdentity insert: %w", err) + } + return nil +} + +// VerifySystemIdentity 常规启动校验:必须恰有一行且与期望一致;缺失不插入。 +func (s *Store) VerifySystemIdentity(instanceID, dbref string) error { + if instanceID == "" { + return fmt.Errorf("VerifySystemIdentity: instance_id 为空") + } + if dbref == "" { + dbref = "sqlite" + } + row, n, err := s.readSystemIdentity() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("%w: agent_system_identity 无行(先 svc init / identity ensure,禁止静默建空身份)", ErrSystemIdentityMissing) + } + if n > 1 { + return fmt.Errorf("%w: agent_system_identity 有 %d 行(期望至多 1)", ErrSystemIdentityMismatch, n) + } + if row.InstanceID != instanceID || row.DBRef != dbref { + return fmt.Errorf("%w: 库内 instance_id=%q dbref=%q ≠ 期望 %q / %q", + ErrSystemIdentityMismatch, row.InstanceID, row.DBRef, instanceID, dbref) + } + return nil +} + +func (s *Store) readSystemIdentity() (SystemIdentity, int, error) { + rows, err := s.db.Query(`SELECT instance_id, dbref, schema_version, created_at FROM agent_system_identity`) + if err != nil { + return SystemIdentity{}, 0, fmt.Errorf("read agent_system_identity: %w", err) + } + defer rows.Close() + var out SystemIdentity + n := 0 + for rows.Next() { + n++ + var id, ref, created string + var ver int + if err := rows.Scan(&id, &ref, &ver, &created); err != nil { + return SystemIdentity{}, 0, err + } + if n == 1 { + out = SystemIdentity{InstanceID: id, DBRef: ref, SchemaVersion: ver, CreatedAt: created} + } + } + if err := rows.Err(); err != nil { + return SystemIdentity{}, 0, err + } + return out, n, nil +} + +// GetSystemIdentity 读取唯一身份行(无行返回 sql.ErrNoRows)。 +func (s *Store) GetSystemIdentity() (SystemIdentity, error) { + row, n, err := s.readSystemIdentity() + if err != nil { + return SystemIdentity{}, err + } + if n == 0 { + return SystemIdentity{}, sql.ErrNoRows + } + if n > 1 { + return SystemIdentity{}, fmt.Errorf("%w: %d rows", ErrSystemIdentityMismatch, n) + } + return row, nil +} diff --git a/internal/store/system_identity_test.go b/internal/store/system_identity_test.go new file mode 100644 index 0000000..e9a61e0 --- /dev/null +++ b/internal/store/system_identity_test.go @@ -0,0 +1,70 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestSystemIdentityEnsureVerifySQLite(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "id.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + if err := st.VerifySystemIdentity("svc-a-aaaaaa", "sqlite"); !errors.Is(err, ErrSystemIdentityMissing) { + t.Fatalf("empty verify: want missing, got %v", err) + } + if err := st.EnsureSystemIdentity("svc-a-aaaaaa", "sqlite"); err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("svc-a-aaaaaa", "sqlite"); err != nil { + t.Fatalf("idempotent ensure: %v", err) + } + if err := st.VerifySystemIdentity("svc-a-aaaaaa", "sqlite"); err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("svc-b-bbbbbb", "sqlite"); !errors.Is(err, ErrSystemIdentityMismatch) { + t.Fatalf("wrong id ensure: want mismatch, got %v", err) + } + if err := st.VerifySystemIdentity("svc-a-aaaaaa", "pg:other"); !errors.Is(err, ErrSystemIdentityMismatch) { + t.Fatalf("wrong dbref verify: want mismatch, got %v", err) + } + row, err := st.GetSystemIdentity() + if err != nil { + t.Fatal(err) + } + if row.InstanceID != "svc-a-aaaaaa" || row.DBRef != "sqlite" { + t.Fatalf("row=%+v", row) + } + if row.SchemaVersion != latestMigrationVersion() { + t.Fatalf("schema_version=%d want %d", row.SchemaVersion, latestMigrationVersion()) + } +} + +func TestSystemIdentityMigrationCreatesTable(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "mig.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + versions, err := st.MigrationStatus() + if err != nil { + t.Fatal(err) + } + found := false + for _, v := range versions { + if v == 26 { + found = true + break + } + } + if !found { + t.Fatalf("migration v26 not applied: %v", versions) + } + var name string + if err := st.DB().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'`).Scan(&name); err != nil || name != "agent_system_identity" { + t.Fatalf("table missing: name=%q err=%v", name, err) + } +} From dcd5445d61eb3cbbfd9c9b87a0ff3a7d3388e3de Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 04:04:39 +0000 Subject: [PATCH 2/3] Include agent_system_identity in storage-migrate copyOrder Why: TestCopyOrderCoversAllFixtureTables fails when new schema tables are omitted from the migrate tool inventory. Evidence: go test ./cmd/storage-migrate/ -run CopyOrder Impact: copyOrder gains agent_system_identity (no FK deps). Refs: U1 I3; Backend PR #170 Co-authored-by: Cursor --- cmd/storage-migrate/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 54bb89f..bb4eb45 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -291,6 +291,8 @@ var copyOrder = []string{ "secret_keys", "settings_snapshots", "regen_pending", "context_entries", "user_commands", "pending_notices", "recovery_holds", "surfaces", "surface_revisions", "surface_actions", "plugin_activation", + // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) + "agent_system_identity", } const controlDDL = ` From e7b70c24f8a456ccb6741f6808c405e2b8af480a Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 05:04:16 +0000 Subject: [PATCH 3/3] Renumber agent_system_identity to v27 after login_keys took v26 Why: main #171 claimed migration v26; rebase conflict required bumping the U1 I3 table and completing copyOrder for both new tables. Evidence: go test ./internal/store/ ./cmd/storage-migrate/ -run 'SystemIdentity|CopyOrder' Impact: migrate v27; copyOrder += login_keys + agent_system_identity. Refs: Backend PR #170; conflict with main 1d9e8f4 Co-authored-by: Cursor --- cmd/storage-migrate/main.go | 2 ++ internal/store/system_identity_test.go | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index bb4eb45..cfee408 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -291,6 +291,8 @@ var copyOrder = []string{ "secret_keys", "settings_snapshots", "regen_pending", "context_entries", "user_commands", "pending_notices", "recovery_holds", "surfaces", "surface_revisions", "surface_actions", "plugin_activation", + // v26 login_keys(#171 合入 main 时漏登记;本 PR 一并补齐) + "login_keys", // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) "agent_system_identity", } diff --git a/internal/store/system_identity_test.go b/internal/store/system_identity_test.go index e9a61e0..f0b4f6e 100644 --- a/internal/store/system_identity_test.go +++ b/internal/store/system_identity_test.go @@ -55,13 +55,13 @@ func TestSystemIdentityMigrationCreatesTable(t *testing.T) { } found := false for _, v := range versions { - if v == 26 { + if v == 27 { found = true break } } if !found { - t.Fatalf("migration v26 not applied: %v", versions) + t.Fatalf("migration v27 not applied: %v", versions) } var name string if err := st.DB().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'`).Scan(&name); err != nil || name != "agent_system_identity" {