From be513de8fd92e5c2ae41f1fced9bafe402f14ee6 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 10:28:20 +0000 Subject: [PATCH 1/3] Add task list APIs with pin and archive flags (R1) Why: the task-centric sidebar (approved option A) needs a cross-project conversation list, server-authoritative pinned/archived state, and an identity endpoint for the sidebar account footer. Evidence: - migration v8 conversation_flags (sqlite/postgres up+down; the upgrade test walks v1<->v8 for real); store and e2e tests cover pinned-first ordering, archived filtering with ?archived=1, tenant-isolation negatives (cross-owner flag write -> NotFound), and /api/me shape (owner/name/auth) in embedded mode. - go vet + full go test ./... pass. Impact: additive columns/APIs; existing endpoints unchanged (PATCH gains optional pinned/archived fields; responses keep prior shape). Refs: core bump deps/agent-system-core -> 1dc08fc (Conversation flags) --- deps/agent-system-core | 2 +- internal/server/e2e_test.go | 58 +++++++++++++++++++++++++++ internal/server/server.go | 28 ++++++++++++- internal/store/migrate.go | 25 ++++++++++++ internal/store/projects.go | 78 ++++++++++++++++++++++++++++++++---- internal/store/store.go | 4 +- internal/store/store_test.go | 55 +++++++++++++++++++++++++ 7 files changed, 238 insertions(+), 12 deletions(-) diff --git a/deps/agent-system-core b/deps/agent-system-core index 0cb0a40..b6d428b 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 0cb0a40dc878f4a706412b7a8e20581684e03c9c +Subproject commit b6d428ba358463986ed59f4b43fc896d9f8374cc diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index e004afc..1c51dbc 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -744,3 +744,61 @@ func TestModelRoutingOverrideChain(t *testing.T) { t.Fatalf("run error should carry profile-routed model name: %q", errMsg) } } + +// TestTaskListAndFlags 方案 A R1:/api/me 身份、跨项目任务列表与置顶/归档 PATCH。 +func TestTaskListAndFlags(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + + p1 := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "P1"}) + p2 := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "P2"}) + c1 := mustCode(t, h, "POST", "/api/projects/"+p1["id"].(string)+"/conversations", map[string]any{"title": "t1"}) + c2 := mustCode(t, h, "POST", "/api/projects/"+p2["id"].(string)+"/conversations", map[string]any{"title": "t2"}) + + // 身份:嵌入式档(无认证中间件)→ 默认租户,auth=false。 + code, me := doJSON(t, h, "GET", "/api/me", nil) + if code != 200 || me["owner"] != store.OwnerDefault || me["auth"] != false { + t.Fatalf("me: code=%d owner=%v auth=%v", code, me["owner"], me["auth"]) + } + if s, _ := me["name"].(string); s == "" { + t.Fatalf("me.name must be non-empty, got %v", me["name"]) + } + + // 跨项目任务列表:两个项目的会话都在。 + code, list := doAny(t, h, "GET", "/api/conversations", nil) + if code != 200 { + t.Fatalf("listTasks code=%d", code) + } + arr, _ := list.([]any) + if len(arr) != 2 { + t.Fatalf("task list must span projects, got %d", len(arr)) + } + + // 置顶 t2 → 列表首;归档 t1 → 默认隐藏、?archived=1 可见。 + mustCode(t, h, "PATCH", "/api/conversations/"+c2["id"].(string), map[string]any{"pinned": true}) + mustCode(t, h, "PATCH", "/api/conversations/"+c1["id"].(string), map[string]any{"archived": true}) + _, list = doAny(t, h, "GET", "/api/conversations", nil) + arr, _ = list.([]any) + if len(arr) != 1 || arr[0].(map[string]any)["id"] != c2["id"] || arr[0].(map[string]any)["pinned"] != true { + t.Fatalf("pinned-first and archived-hidden expected, got %+v", list) + } + _, list = doAny(t, h, "GET", "/api/conversations?archived=1", nil) + arr, _ = list.([]any) + if len(arr) != 2 { + t.Fatalf("archived=1 must include archived, got %d", len(arr)) + } + + // 解除归档恢复可见。 + mustCode(t, h, "PATCH", "/api/conversations/"+c1["id"].(string), map[string]any{"archived": false}) + _, list = doAny(t, h, "GET", "/api/conversations", nil) + arr, _ = list.([]any) + if len(arr) != 2 { + t.Fatalf("unarchive must restore visibility, got %d", len(arr)) + } + + // 详情读取带标记(对账权威路径)。 + det := mustCode(t, h, "GET", "/api/conversations/"+c2["id"].(string), nil) + if det["conversation"].(map[string]any)["pinned"] != true { + t.Fatalf("detail must carry pinned flag, got %+v", det["conversation"]) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7737e47..6cc95b2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -134,6 +134,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("DELETE /api/projects/{id}", s.deleteProject) mux.HandleFunc("GET /api/projects/{id}/conversations", s.listConversations) mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation) + mux.HandleFunc("GET /api/conversations", s.listTasks) mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail) mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation) mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation) @@ -190,6 +191,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge) mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge) + mux.HandleFunc("GET /api/me", s.me) mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": time.Now().UTC()}) }) @@ -376,6 +378,23 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { writeJSONOr(w, cs, err) } +// listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。 +func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { + v := r.URL.Query().Get("archived") + cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true") + writeJSONOr(w, cs, err) +} + +// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。 +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + owner := s.owner(r) + name := owner + if u, err := s.st.GetUser(owner); err == nil && u.Name != "" { + name = u.Name + } + writeJSON(w, http.StatusOK, map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil}) +} + func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { var req struct { Title string `json:"title"` @@ -413,9 +432,15 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { var req struct { Title string `json:"title"` ProfileID string `json:"profileId"` + Pinned *bool `json:"pinned"` + Archived *bool `json:"archived"` } json.NewDecoder(r.Body).Decode(&req) - writeOKOr(w, s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID)) + if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { + writeStoreErr(w, err) + return + } + writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived)) } func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) { @@ -1082,7 +1107,6 @@ func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) { } writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id"))) } - func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, ingest.ParsersDescription()) } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 50064f3..b0901ad 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -237,6 +237,31 @@ func init() { `CREATE TABLE IF NOT EXISTS prompt_modules (id TEXT NOT NULL, owner TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(id, owner))`, }}, down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS prompt_modules`}, "postgres": {`DROP TABLE IF EXISTS prompt_modules`}}}, + migration{version: 9, name: "conversation_flags", + // 会话置顶/归档(提案 2026-09-30 方案 A R1):默认 0 兼容既有行; + // owner+updated_at 索引服务跨项目任务列表(GET /api/conversations)。 + up: map[string][]string{"sqlite": { + `ALTER TABLE conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`, + `CREATE INDEX IF NOT EXISTS idx_conversations_owner ON conversations(owner, updated_at)`, + }, "postgres": { + `ALTER TABLE conversations ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT FALSE`, + `ALTER TABLE conversations ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE`, + `CREATE INDEX IF NOT EXISTS idx_conversations_owner ON conversations(owner, updated_at)`, + }}, + down: map[string][]string{"sqlite": { + // SQLite 兼容路径不假设支持 DROP COLUMN:重建去列; + // 回滚丢弃置顶/归档偏好(本端组织态),会话本体保留。 + `DROP INDEX IF EXISTS idx_conversations_owner`, + `CREATE TABLE conversations_old (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `INSERT INTO conversations_old(id, project_id, title, profile_id, owner, created_at, updated_at) SELECT id, project_id, title, profile_id, owner, created_at, updated_at FROM conversations`, + `DROP TABLE conversations`, + `ALTER TABLE conversations_old RENAME TO conversations`, + }, "postgres": { + `DROP INDEX IF EXISTS idx_conversations_owner`, + `ALTER TABLE conversations DROP COLUMN archived`, + `ALTER TABLE conversations DROP COLUMN pinned`, + }}}, ) } diff --git a/internal/store/projects.go b/internal/store/projects.go index d93dfb3..639d972 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "strings" "time" core "agent-system-core" @@ -136,7 +137,7 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c } func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) { - rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) + rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) if err != nil { return nil, err } @@ -144,23 +145,58 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, out := []core.Conversation{} for rows.Next() { var c core.Conversation - var ca, ua string - if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua); err != nil { + if err := scanConversation(rows, &c); err != nil { return nil, err } c.Owner = owner - c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) - c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua) out = append(out, c) } return out, rows.Err() } +// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。 +func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) { + q := `SELECT id,project_id,title,profile_id,pinned,archived,created_at,updated_at FROM conversations WHERE owner=?` + if !includeArchived { + q += ` AND archived=0` + } + q += ` ORDER BY pinned DESC, updated_at DESC` + rows, err := s.db.Query(s.q(q), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []core.Conversation{} + for rows.Next() { + var c core.Conversation + if err := scanConversation(rows, &c); err != nil { + return nil, err + } + c.Owner = owner + out = append(out, c) + } + return out, rows.Err() +} + +func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error { + var ca, ua string + var pinned, archived int + if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &ca, &ua); err != nil { + return err + } + c.Pinned = pinned == 1 + c.Archived = archived == 1 + c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua) + return nil +} + func (s *Store) GetConversation(owner, id string) (core.Conversation, error) { var c core.Conversation var ca, ua string - err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner). - Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua) + var pinned, archived int + err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner). + Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &ca, &ua) if errors.Is(err, sql.ErrNoRows) { return c, ErrNotFound } @@ -168,6 +204,8 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) { return c, err } c.Owner = owner + c.Pinned = pinned == 1 + c.Archived = archived == 1 c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua) return c, nil @@ -198,6 +236,32 @@ func (s *Store) UpdateConversation(owner, id, title, profileID string) error { return nil } +// SetConversationFlags 更新会话置顶/归档标记(nil = 该字段不动;方案 A R1)。 +func (s *Store) SetConversationFlags(owner, id string, pinned, archived *bool) error { + var sets []string + var args []any + if pinned != nil { + sets = append(sets, "pinned=?") + args = append(args, boolInt(*pinned)) + } + if archived != nil { + sets = append(sets, "archived=?") + args = append(args, boolInt(*archived)) + } + if len(sets) == 0 { + return nil + } + args = append(args, nowISO(), id, owner) + res, err := s.db.Exec(s.q(`UPDATE conversations SET `+strings.Join(sets, ", ")+`, updated_at=? WHERE id=? AND owner=?`), args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + func (s *Store) DeleteConversation(owner, id string) error { return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) } diff --git a/internal/store/store.go b/internal/store/store.go index 594c8fe..2c04322 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -32,8 +32,8 @@ const OwnerDefault = "local" type Store struct { db *sql.DB dialect dialect - dataDir string // 工件对象目录(备份路径等诊断用) - blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储) + dataDir string // 工件对象目录(备份路径等诊断用) + blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储) } // OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。 diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 04b53e0..6895071 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -396,3 +396,58 @@ func TestProfileTenantScope(t *testing.T) { t.Fatalf("cross-tenant profile: %v", err) } } + +// TestConversationFlagsAndTaskList 方案 A R1:置顶/归档标记与跨项目任务列表(租户隔离负例)。 +func TestConversationFlagsAndTaskList(t *testing.T) { + st := openTestStore(t) + a, b := "alice", "bob" + pa, err := st.CreateProject(a, "甲") + if err != nil { + t.Fatal(err) + } + pb, err := st.CreateProject(a, "乙") + if err != nil { + t.Fatal(err) + } + ca, err := st.CreateConversation(a, pa.ID, "t1", "") + if err != nil { + t.Fatal(err) + } + cb, err := st.CreateConversation(a, pb.ID, "t2", "") + if err != nil { + t.Fatal(err) + } + yes := true + + // 置顶跨项目列表排首;归档默认隐藏。 + if err := st.SetConversationFlags(a, cb.ID, &yes, nil); err != nil { + t.Fatal(err) + } + tasks, err := st.ListTasks(a, false) + if err != nil { + t.Fatal(err) + } + if len(tasks) != 2 || tasks[0].ID != cb.ID || !tasks[0].Pinned { + t.Fatalf("pinned task must sort first, got %+v", tasks) + } + if err := st.SetConversationFlags(a, ca.ID, nil, &yes); err != nil { + t.Fatal(err) + } + tasks, _ = st.ListTasks(a, false) + if len(tasks) != 1 || tasks[0].ID != cb.ID { + t.Fatalf("archived must be hidden by default, got %+v", tasks) + } + tasks, _ = st.ListTasks(a, true) + if len(tasks) != 2 { + t.Fatalf("includeArchived must return all, got %d", len(tasks)) + } + + // 跨租户标记写入一律 NotFound。 + no := false + if err := st.SetConversationFlags(b, ca.ID, &no, nil); err != ErrNotFound { + t.Fatalf("cross-tenant flag write must be NotFound, got %v", err) + } + if got, _ := st.ListTasks(b, true); len(got) != 0 { + t.Fatalf("task list must be tenant-scoped, got %d", len(got)) + } +} -- 2.54.0 From 5871c3e771160a25eb8b6625f2ae585dc7fb8ee6 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 10:37:41 +0000 Subject: [PATCH 2/3] Fix /api/me auth flag to respect Authenticator.Enabled Why: a non-nil Authenticator with Enabled=false is the embedded no-auth mode; reporting auth=true there made the sidebar account footer show the wrong identity state (found in the live headless walk). Evidence: restarted binary now returns {"auth":false,...} in embedded mode; TestTaskListAndFlags still green. Impact: response field value only. --- internal/server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/server.go b/internal/server/server.go index 6cc95b2..b04f619 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -392,7 +392,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { if u, err := s.st.GetUser(owner); err == nil && u.Name != "" { name = u.Name } - writeJSON(w, http.StatusOK, map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil}) + writeJSON(w, http.StatusOK, map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled}) } func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { -- 2.54.0 From 9660490c3108acd0adc8fc9fb51c9131b5d88004 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 10:41:05 +0000 Subject: [PATCH 3/3] Renumber conversation_flags to v9 after prompt_modules v8 Why: upstream merged prompt_modules as migration v8; the version namespace is linear so the rebased task-sidebar work moves to v9. Evidence: go vet + full go test ./... green; migration upgrade test now walks v1<->v9 (up and down paths exercised for real). Impact: fresh databases apply v8 (prompts) then v9 (conversation flags); no released v8 conversation_flags ever existed, so no deployed database can hold the old numbering. --- internal/store/store_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6895071..8709d11 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 8 { - t.Fatalf("expected versions [1..8], got %v", got) + if len(got) != 9 { + t.Fatalf("expected versions [1..9], got %v", got) } // 老库形态(v2 前):events 全局自增、settings 单列主键。 if err := st.MigrateDown(1); err != nil { -- 2.54.0