ci / vet-and-test (pull_request) Successful in 41s
Why: the approved plan U3 (KTD3/KTD7/KTD10 plus minimal recovery isolation) requires concurrent submits to not silently overwrite, unknown-side-effect runs to never auto-resume, and the shared rate limiter to degrade conservatively instead of failing open. Evidence: full backend suite green twice-relevant paths against real PostgreSQL 18.6 and Redis (isolated instance) and on SQLite; new tests - card_version_test (concurrent single winner), recovery_hold_test + recovery_holds_test (skip/release/fail-closed CRUD), ratelimit atomic-window and degraded-fallback tests. Impact: AppendMessageWithRequest now allocates seq under the conversation advisory lock in one transaction; activity_cards gain version (v19) with SaveCardVersioned/GetCardVersion/CardVersion and cardsvc user mutations (status/pin/action/resolve-by-ref) return 409-mapped conflicts while system mirrors keep blind writes; recovery_holds (v20) with admin list/release endpoints and fail-closed resume skip; Redis limiter uses one Lua INCR+PEXPIRE script and falls back to a per-instance limit with a Degraded counter. Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 KTD3/KTD7/KTD10; R3/R6/R7/R8.
172 lines
6.7 KiB
Go
172 lines
6.7 KiB
Go
package store
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// 收件箱任务卡持久化:payload 存 core.ActivityCard 整卡 JSON(卡片是可变状态对象,
|
||
// 服务端权威);status/pinned/ref 冗余列支撑过滤、排序与 HITL 投影联动。
|
||
|
||
// SaveCard 插入或更新整卡(upsert)。
|
||
func (s *Store) SaveCard(owner string, c *core.ActivityCard, ref string) error {
|
||
payload, err := json.Marshal(c)
|
||
if err != nil {
|
||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||
}
|
||
_, err = s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,1)
|
||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1`),
|
||
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano))
|
||
if err != nil {
|
||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SaveCardVersioned 乐观并发保存(U3,KTD7):行存在且 expected 与当前
|
||
// version 一致才应用并递增,否则 ErrConflict(调用方回 409 让客户端重读
|
||
// 重试);行不存在时按新卡插入 version=1(expected==0 的建卡语义)。
|
||
// 现产品没有卡片删除路径,expected>0 而行缺失不可达;若未来引入删除,
|
||
// 此处须补「插入回滚为冲突」的语义。
|
||
func (s *Store) SaveCardVersioned(owner string, c *core.ActivityCard, ref string, expected int) error {
|
||
payload, err := json.Marshal(c)
|
||
if err != nil {
|
||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||
}
|
||
res, err := s.db.Exec(s.q(`INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,1)
|
||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, status=excluded.status, pinned=excluded.pinned, ref=CASE WHEN excluded.ref='' THEN activity_cards.ref ELSE excluded.ref END, updated_at=excluded.updated_at, version=activity_cards.version+1
|
||
WHERE activity_cards.version=?`),
|
||
c.ID, c.ProjectID, c.ConversationID, ref, owner, string(payload), string(c.Status), boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano), expected)
|
||
if err != nil {
|
||
return fmt.Errorf("save card %s: %w", c.ID, err)
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
// DO UPDATE 的 WHERE 未命中:行存在但版本不匹配 → 明确冲突(客户端重读)。
|
||
return fmt.Errorf("save card %s: %w", c.ID, ErrConflict)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CardVersion 返回当前版本号(乐观并发的读侧)。
|
||
func (s *Store) CardVersion(owner, id string) (int, error) {
|
||
var v int
|
||
err := s.db.QueryRow(s.q(`SELECT version FROM activity_cards WHERE id=? AND owner=?`), id, owner).Scan(&v)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return 0, ErrNotFound
|
||
}
|
||
return 0, err
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// GetCardVersion 读卡与当前版本(乐观并发的读侧;U3 KTD7)。
|
||
func (s *Store) GetCardVersion(owner, id string) (core.ActivityCard, int, error) {
|
||
row := s.db.QueryRow(s.q(`SELECT payload, version FROM activity_cards WHERE id=? AND owner=?`), id, owner)
|
||
var payload string
|
||
var ver int
|
||
if err := row.Scan(&payload, &ver); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.ActivityCard{}, 0, ErrNotFound
|
||
}
|
||
return core.ActivityCard{}, 0, err
|
||
}
|
||
var c core.ActivityCard
|
||
if err := json.Unmarshal([]byte(payload), &c); err != nil {
|
||
return core.ActivityCard{}, 0, fmt.Errorf("card %s payload: %w", id, err)
|
||
}
|
||
return c, ver, nil
|
||
}
|
||
|
||
func (s *Store) GetCard(owner, id string) (core.ActivityCard, error) {
|
||
row := s.db.QueryRow(s.q(`SELECT payload FROM activity_cards WHERE id=? AND owner=?`), id, owner)
|
||
var payload string
|
||
if err := row.Scan(&payload); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return core.ActivityCard{}, ErrNotFound
|
||
}
|
||
return core.ActivityCard{}, err
|
||
}
|
||
var c core.ActivityCard
|
||
if err := json.Unmarshal([]byte(payload), &c); err != nil {
|
||
return core.ActivityCard{}, fmt.Errorf("card %s payload: %w", id, err)
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
func (s *Store) ListCards(owner, conversationID string) ([]core.ActivityCard, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT payload FROM activity_cards WHERE conversation_id=? AND owner=? ORDER BY updated_at DESC`), conversationID, owner)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list cards: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []core.ActivityCard
|
||
for rows.Next() {
|
||
var payload string
|
||
if err := rows.Scan(&payload); err != nil {
|
||
return nil, err
|
||
}
|
||
var c core.ActivityCard
|
||
if err := json.Unmarshal([]byte(payload), &c); err != nil {
|
||
return nil, fmt.Errorf("card payload: %w", err)
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ListProjectCards 项目收件箱聚合(D49 拍板口径):项目级卡(conversation_id
|
||
// 为空,任意状态)∪ 该项目全部会话的未决卡(action_required);done 卡不聚合
|
||
// (留在原会话,防噪音)。
|
||
func (s *Store) ListProjectCards(owner, projectID string) ([]core.ActivityCard, error) {
|
||
rows, err := s.db.Query(s.q(`SELECT payload FROM activity_cards WHERE project_id=? AND owner=? AND (conversation_id='' OR status=?) ORDER BY updated_at DESC`), projectID, owner, string(core.CardActionRequired))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list project cards: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []core.ActivityCard
|
||
for rows.Next() {
|
||
var payload string
|
||
if err := rows.Scan(&payload); err != nil {
|
||
return nil, err
|
||
}
|
||
var c core.ActivityCard
|
||
if err := json.Unmarshal([]byte(payload), &c); err != nil {
|
||
return nil, fmt.Errorf("card payload: %w", err)
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// CardRef 取卡的 ref 列(命令确认卡回道判定:"cmd:<id>" 前缀)。
|
||
func (s *Store) CardRef(owner, id string) (string, error) {
|
||
row := s.db.QueryRow(s.q(`SELECT ref FROM activity_cards WHERE id=? AND owner=?`), id, owner)
|
||
var ref string
|
||
if err := row.Scan(&ref); err != nil {
|
||
return "", err
|
||
}
|
||
return ref, nil
|
||
}
|
||
|
||
// CardIDByRef 按引用取卡 ID(HITL 投影联动:"hitl:<id>";最新一条)。
|
||
func (s *Store) CardIDByRef(owner, ref string) (string, error) {
|
||
row := s.db.QueryRow(s.q(`SELECT id FROM activity_cards WHERE ref=? AND owner=? ORDER BY updated_at DESC LIMIT 1`), ref, owner)
|
||
var id string
|
||
err := row.Scan(&id)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return "", ErrNotFound
|
||
}
|
||
return "", err
|
||
}
|
||
return id, nil
|
||
}
|