Task list APIs with pin/archive flags and /api/me (R1) #15
Vendored
+1
-1
Submodule deps/agent-system-core updated: 0cb0a40dc8...b6d428ba35
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && s.Auth.Enabled})
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 打开(必要时创建)嵌入式存储并执行迁移。
|
||||
|
||||
@@ -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 {
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user