package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" core "agent-system-core" ) // ---- 工件(元数据在库,内容在对象目录 data/artifacts/)---- // 文件写走 临时文件 → rename → 落库:崩溃残留只可能产生无主孤立文件, // 不产生库里有行、对象缺失的悬空引用。 func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error { if a.ID == "" { a.ID = newID("art") } now := time.Now().UTC() a.CreatedAt, a.UpdatedAt = now, now a.Size = int64(len(content)) ref, err := s.blobs.Put(a.ID, content) if err != nil { return err } _, err = s.db.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`), a.ID, a.ProjectID, a.Owner, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO()) if err != nil { _ = s.blobs.Delete(ref) // 落库失败:回收对象,不留悬空 return err } return nil } // CreateArtifactAccountChecked stages content under a fresh, unreferenced key. // The artifact only becomes visible when its row commits with the actor's // effective-account check. A rejected transaction cannot expose staged bytes. func (s *Store) CreateArtifactAccountChecked(actor string, a *core.Artifact, content []byte, allowImplicitDefault bool) error { if a == nil || a.Owner != actor { return ErrNotFound } if actor == "" { return ErrAccountForbidden } if a.ID == "" { a.ID = newID("art") } now := time.Now().UTC() a.CreatedAt, a.UpdatedAt = now, now a.Size = int64(len(content)) ref, err := s.blobs.Put(newID("blob"), content) if err != nil { return err } var callbackReached bool var callbackErr error err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { callbackReached = true _, callbackErr = tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`), a.ID, a.ProjectID, actor, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO()) return callbackErr }) if err != nil && (!callbackReached || callbackErr != nil) { _ = s.blobs.Delete(ref) } return err } func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error { // Legacy callers retain the old signature, but share immutable replacement: // account-checked creations may already point at a key other than the ID. ref, err := s.blobs.Put(newID("blob"), content) if err != nil { return err } tx, err := s.db.Begin() if err != nil { _ = s.blobs.Delete(ref) return err } defer tx.Rollback() oldRef, err := s.switchArtifactContentTx(tx, owner, id, ref, len(content)) if err != nil { _ = tx.Rollback() _ = s.blobs.Delete(ref) return err } if err := tx.Commit(); err != nil { // The commit may have reached the DB. Preserve the staged object. return err } // The DB pointer is authoritative. Blob deletion is external and may fail // after a successful commit; callers can retry cleanup separately. if oldRef == "" { return nil } return s.blobs.Delete(oldRef) } // UpdateArtifactContentAccountChecked swaps only the visible DB reference in // the account-guarded transaction. Never overwrite the old referenced object. func (s *Store) UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error { if actor == "" { return ErrAccountForbidden } ref, err := s.blobs.Put(newID("blob"), content) if err != nil { return err } var oldRef string var callbackReached bool var callbackErr error err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { callbackReached = true oldRef, callbackErr = s.switchArtifactContentTx(tx, actor, id, ref, len(content)) return callbackErr }) if err != nil && (!callbackReached || callbackErr != nil) { _ = s.blobs.Delete(ref) return err } if err != nil { return err } // The old object can be removed only after the new reference commits. if oldRef == "" { return nil } return s.blobs.Delete(oldRef) } func (s *Store) switchArtifactContentTx(tx *sql.Tx, owner, id, newRef string, size int) (string, error) { // Serialize overwrites with task projection source-version witnesses. if err := s.dialect.lockScope(tx, owner, "artifact:"+id); err != nil { return "", err } query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`) if s.Dialect() == "postgres" { query += ` FOR UPDATE` } var oldRef string if err := tx.QueryRow(query, id, owner).Scan(&oldRef); errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } else if err != nil { return "", err } res, err := tx.Exec(s.q(`UPDATE artifacts SET content_path=?, size=?, updated_at=? WHERE id=? AND owner=?`), newRef, size, nowISO(), id, owner) if err != nil { return "", err } if n, err := res.RowsAffected(); err != nil { return "", err } else if n != 1 { return "", ErrNotFound } return oldRef, nil } const artifactColumns = `id,project_id,owner,name,kind,mime,size,source_run,created_at,updated_at` func scanArtifact(scan func(...any) error) (core.Artifact, error) { var a core.Artifact var owner, mime, sourceRun string var ca, ua string if err := scan(&a.ID, &a.ProjectID, &owner, &a.Name, &a.Kind, &mime, &a.Size, &sourceRun, &ca, &ua); err != nil { return a, err } a.Owner, a.MIME, a.SourceRun = owner, mime, sourceRun a.CreatedAt, _ = parseTime(ca) a.UpdatedAt, _ = parseTime(ua) return a, nil } func (s *Store) GetArtifact(owner, id string) (core.Artifact, error) { a, err := scanArtifact(func(dst ...any) error { return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(dst...) }) if errors.Is(err, sql.ErrNoRows) { return a, ErrNotFound } return a, err } func (s *Store) ArtifactContent(owner, id string) ([]byte, error) { var ref string err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&ref) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return s.blobs.Get(ref) } func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error) { rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) if err != nil { return nil, err } defer rows.Close() out := []core.Artifact{} for rows.Next() { a, err := scanArtifact(rows.Scan) if err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } // ---- EXT-8 共享读取助手:无 owner 过滤,仅供 HTTP 层在 // authorizeSharedProject 判定通过后使用;其他调用点一律禁止(会破坏租户边界)。---- // GetArtifactByID 按工件 ID 取元数据(无 owner 过滤;调用方须先过 EXT-8 判定)。 func (s *Store) GetArtifactByID(id string) (core.Artifact, error) { a, err := scanArtifact(func(dst ...any) error { return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`), id).Scan(dst...) }) if errors.Is(err, sql.ErrNoRows) { return a, ErrNotFound } return a, err } // ArtifactContentByID 按工件 ID 取内容(无 owner 过滤;调用方须先过 EXT-8 判定)。 func (s *Store) ArtifactContentByID(id string) ([]byte, error) { var ref string err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=?`), id).Scan(&ref) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return s.blobs.Get(ref) } // ListProjectArtifactsAll 项目全部工件(含其他 owner;调用方须先过 EXT-8 判定)。 func (s *Store) ListProjectArtifactsAll(projectID string) ([]core.Artifact, error) { rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`), projectID) if err != nil { return nil, err } defer rows.Close() out := []core.Artifact{} for rows.Next() { a, err := scanArtifact(rows.Scan) if err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } // DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。 func (s *Store) DeleteArtifact(owner, id string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if err = s.dialect.lockScope(tx, owner, "artifact:"+id); err != nil { return err } var path string err = tx.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } if err != nil { return err } if _, err = tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, owner); err != nil { return err } if err = tx.Commit(); err != nil { return err } // Preserve the existing lexical/vector cleanup after the authoritative delete. _ = s.DeleteKnowledgeByRef(owner, id) _ = s.DeleteKnowledgeVectors(context.Background(), owner, id) return s.blobs.Delete(path) } // DeleteArtifactAccountChecked removes Backend-owned rows in one guarded // transaction. The blob is unlinked only after commit; an unlink failure may // leave an orphan, but cannot restore a deleted DB row or authorize a late write. func (s *Store) DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error { var ref string err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { if err := s.dialect.lockScope(tx, actor, "artifact:"+id); err != nil { return err } query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`) if s.Dialect() == "postgres" { query += ` FOR UPDATE` } if err := tx.QueryRow(query, id, actor).Scan(&ref); errors.Is(err, sql.ErrNoRows) { return ErrNotFound } else if err != nil { return err } if _, err := tx.Exec(s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), id, actor); err != nil { return err } if _, err := tx.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), id, actor); err != nil { return err } res, err := tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, actor) if err != nil { return err } if n, err := res.RowsAffected(); err != nil { return err } else if n != 1 { return ErrNotFound } return nil }) if err != nil { return err } return s.blobs.Delete(ref) } // ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)---- func (s *Store) SaveHITLCard(owner string, c *core.HITLCard) error { _, err := s.db.Exec(s.q(`INSERT INTO hitl_cards(id,owner,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, resolved=excluded.resolved, resolution=excluded.resolution`), c.ID, owner, c.RunID, c.ConversationID, marshalJSON(c), nowISO(), boolInt(c.Resolved), "") return err } func (s *Store) GetHITLCard(owner, id string) (core.HITLCard, error) { var payload string err := s.db.QueryRow(s.q(`SELECT payload FROM hitl_cards WHERE id=? AND owner=?`), id, owner).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { return core.HITLCard{}, ErrNotFound } if err != nil { return core.HITLCard{}, err } return unmarshalJSON[core.HITLCard](payload) } func (s *Store) HITLResolution(owner, id string) (core.HITLResolvedPayload, error) { var payload string err := s.db.QueryRow(s.q(`SELECT resolution FROM hitl_cards WHERE id=? AND owner=? AND resolved=1`), id, owner).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { return core.HITLResolvedPayload{}, ErrNotFound } if err != nil { return core.HITLResolvedPayload{}, err } return unmarshalJSON[core.HITLResolvedPayload](payload) } // ResolveHITLCard 原子置为已解决;已解决的重复请求返回明确错误(先查后改竞态修复)。 func (s *Store) ResolveHITLCard(owner, id string, resolution core.HITLResolvedPayload) error { c, err := s.GetHITLCard(owner, id) if err != nil { return err } if c.Resolved { return fmt.Errorf("store: card %s already resolved", id) } c.Resolved = true res, err := s.db.Exec(s.q(`UPDATE hitl_cards SET resolved=1, payload=?, resolution=? WHERE id=? AND owner=? AND resolved=0`), marshalJSON(c), marshalJSON(resolution), id, owner) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("store: card %s already resolved", id) } return nil } func (s *Store) ActiveHITLCards(owner, conversationID string) ([]core.HITLCard, error) { rows, err := s.db.Query(s.q(`SELECT payload FROM hitl_cards WHERE conversation_id=? AND owner=? AND resolved=0 ORDER BY created_at`), conversationID, owner) if err != nil { return nil, err } defer rows.Close() out := []core.HITLCard{} for rows.Next() { var payload string if err := rows.Scan(&payload); err != nil { return nil, err } c, _ := unmarshalJSON[core.HITLCard](payload) out = append(out, c) } return out, rows.Err() } func boolInt(b bool) int { if b { return 1 } return 0 } // ---- 计划 / 待办(会话域;归属由上游 GetConversation 校验)---- func (s *Store) SavePlan(conversationID string, p core.PlanSnapshot) error { _, err := s.db.Exec(s.q(`INSERT INTO plans(conversation_id,payload) VALUES(?,?) ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, marshalJSON(p)) return err } func (s *Store) GetPlan(conversationID string) (core.PlanSnapshot, error) { var payload string err := s.db.QueryRow(s.q(`SELECT payload FROM plans WHERE conversation_id=?`), conversationID).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { return core.PlanSnapshot{}, ErrNotFound } if err != nil { return core.PlanSnapshot{}, err } return unmarshalJSON[core.PlanSnapshot](payload) } func (s *Store) SaveTodos(conversationID string, l core.TodoList) error { _, err := s.db.Exec(s.q(`INSERT INTO todos(conversation_id,payload) VALUES(?,?) ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, marshalJSON(l)) return err } func (s *Store) GetTodos(conversationID string) (core.TodoList, error) { var payload string err := s.db.QueryRow(s.q(`SELECT payload FROM todos WHERE conversation_id=?`), conversationID).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { return core.TodoList{}, ErrNotFound } if err != nil { return core.TodoList{}, err } return unmarshalJSON[core.TodoList](payload) } // ---- 知识(全文检索:SQLite FTS5 / PG tsvector+trigram;owner='' 为共享知识)---- // AddKnowledge 单事务批量写入:中途失败整体回滚,不再产生半批导入。 func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error { if len(docs) == 0 { return nil } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() if err := s.addKnowledgeTx(tx, docs); err != nil { return err } return tx.Commit() } // AddKnowledgeAccountChecked keeps the whole batch behind one final account // check and transaction. A caller cannot publish rows under another owner. func (s *Store) AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { if len(docs) == 0 { return nil } for _, doc := range docs { if doc.Owner != core.ID(actor) { return ErrNotFound } } return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { return s.addKnowledgeTx(tx, docs) }) } // ReplaceKnowledgeAccountChecked atomically removes the affected publication // refs and inserts all replacement chunks. A failed insert rolls the removals // back; embeddings and their later vectors are separate guarded operations. // scoped=true limits each ref to the exact project and plugin namespace. func (s *Store) ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { if len(docs) == 0 { return nil } if scoped && pluginID == "" { return ErrNotFound } prefix := "plugin:" + pluginID type publicationRef struct{ projectID, ref string } refs := map[publicationRef]bool{} for _, doc := range docs { if doc.Owner != core.ID(actor) { return ErrNotFound } if scoped && doc.Source != prefix && !strings.HasPrefix(doc.Source, prefix+":") { return ErrNotFound } if doc.Ref != "" { refs[publicationRef{projectID: string(doc.ProjectID), ref: doc.Ref}] = true } } return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { if err := ctx.Err(); err != nil { return err } for ref := range refs { pred := `owner=? AND ref=?` args := []any{actor, ref.ref} if scoped { pred += ` AND project_id=? AND (source=? OR substr(source,1,length(?))=?)` args = append(args, ref.projectID, prefix, prefix+":", prefix+":") } else if ref.projectID != "" { pred += ` AND project_id=?` args = append(args, ref.projectID) } if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil { return err } } if err := ctx.Err(); err != nil { return err } return s.addKnowledgeTx(tx, docs) }) } func (s *Store) addKnowledgeTx(tx *sql.Tx, docs []core.KnowledgeDoc) error { ts := nowISO() // 按下标写回生成的 ID:调用方(rag 管线)用 ID 关联向量。 for i := range docs { if docs[i].ID == "" { docs[i].ID = newID("knw") } if _, err := tx.Exec(s.q(`INSERT INTO knowledge(id,owner,project_id,source,title,ref,chunk,created_at) VALUES(?,?,?,?,?,?,?,?)`), docs[i].ID, docs[i].Owner, docs[i].ProjectID, docs[i].Source, docs[i].Title, docs[i].Ref, docs[i].Chunk, ts); err != nil { return err } } return nil } // DeleteKnowledgeByRef 删除引用对应的知识(工件删除时联动清索引)。 func (s *Store) DeleteKnowledgeByRef(owner, ref string) error { _, err := s.db.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), ref, owner) return err } // DeleteKnowledgeByRefAccountChecked removes the index rows and their vectors // in the same account-guarded transaction. func (s *Store) DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error { _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, `owner=? AND ref=?`, actor, ref) return err } // DeleteKnowledgeBySourceAccountChecked treats the prefix literally and only // matches its namespace (prefix itself or prefix followed by ':'). SQL LIKE // would treat plugin IDs containing '_' or '%' as wildcards. func (s *Store) DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) { if sourcePrefix == "" { return 0, ErrNotFound } childPrefix := sourcePrefix + ":" return s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, `owner=? AND (source=? OR substr(source,1,length(?))=?)`, actor, sourcePrefix, childPrefix, childPrefix) } func (s *Store) deleteKnowledgeAccountChecked(ctx context.Context, actor string, allowImplicitDefault bool, pred string, args ...any) (int64, error) { var deleted int64 err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { var err error deleted, err = s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...) return err }) return deleted, err } // DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配 // 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在 // KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。 func (s *Store) DeleteKnowledgeBySource(ctx context.Context, owner, sourcePrefix string) (int64, error) { rows, err := s.db.QueryContext(ctx, s.q(`SELECT DISTINCT ref FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%") if err != nil { return 0, err } refs := []string{} for rows.Next() { var r string if err := rows.Scan(&r); err != nil { rows.Close() return 0, err } refs = append(refs, r) } rows.Close() if err := rows.Err(); err != nil { return 0, err } for _, r := range refs { if err := s.DeleteKnowledgeVectors(ctx, owner, r); err != nil { return 0, err } } res, err := s.db.ExecContext(ctx, s.q(`DELETE FROM knowledge WHERE owner=? AND source LIKE ?`), owner, sourcePrefix+"%") if err != nil { return 0, err } n, _ := res.RowsAffected() return n, nil } func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { if s.dialect.name() == "postgres" { return s.searchKnowledgePG(owner, q) } return s.searchKnowledgeSQLite(owner, q) } // knowledgePred 命中可见性谓词(owner 作用域或共享作用域由调用方决定)。 type knowledgePred func(d core.KnowledgeDoc) bool func ownerVisible(owner string, q core.RetrievalQuery) knowledgePred { return func(d core.KnowledgeDoc) bool { return knowledgeVisible(owner, q, d) } } // sharedVisible EXT-8 切片 2:他人 owner 且其 ProjectID 经授权回调放行的行可见; // 无 ProjectID 的行无法授权(拒绝);项目/来源过滤沿用查询口径。 func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) knowledgePred { return func(d core.KnowledgeDoc) bool { if d.Owner == "" || d.Owner == requester || d.ProjectID == "" { return false } if q.ProjectID != "" && d.ProjectID != q.ProjectID { return false } if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) { return false } return allow(d.Owner, d.ProjectID) } } // SearchKnowledgeShared 共享检索(EXT-8 切片 2):返回他人 owner 的项目知识中, // 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。 // allow 出错由调用方前置处理——本层只见布尔。 func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { pred := sharedVisible(requester, q, allow) if s.dialect.name() == "postgres" { return s.searchKnowledgePGShared(requester, q, pred) } return s.searchKnowledgeSQLiteShared(requester, q, pred) } // SearchKnowledgeSharedAuthorized evaluates the trusted document identity before // returning a shared row. Project-level permissions cannot grant private content. func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { pred := func(d core.KnowledgeDoc) bool { return d.Owner != "" && d.Owner != requester && d.ProjectID != "" && (q.ProjectID == "" || d.ProjectID == q.ProjectID) && (len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d) } if s.dialect.name() == "postgres" { return s.searchKnowledgePGSharedCandidates(requester, q, pred, false) } out, err := s.searchKnowledgeFTSCandidates(pred, q, false) if err != nil { return nil, err } if len(out) == 0 && q.Text != "" { return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, false) } return out, nil } // DeleteProjectKnowledgeByRef preserves the ordinary legacy replacement scope. // Registered lifecycle publishers use DeletePluginProjectKnowledgeByRef instead. func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error { args := []any{owner, ref} pred := "owner=? AND ref=?" if projectID != "" { pred += " AND project_id=?" args = append(args, projectID) } return s.deleteKnowledgeAndVectors(ctx, pred, args...) } // DeleteProjectKnowledgeByRefAccountChecked retains legacy project scoping: // empty projectID means all of this actor's projects for the given ref. func (s *Store) DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error { args := []any{actor, ref} pred := `owner=? AND ref=?` if projectID != "" { pred += ` AND project_id=?` args = append(args, projectID) } _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, args...) return err } // DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication // scope. Empty project is an exact unscoped domain, never a wildcard. The source // prefix is compared literally, including any percent/underscore in plugin IDs. func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref, pluginID string) error { if owner == "" || pluginID == "" { return errors.New("store: publication owner and plugin required") } prefix := "plugin:" + pluginID pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)` return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":") } // DeletePluginProjectKnowledgeByRefAccountChecked retains the exact project, // ref and plugin namespace restriction, including literal '%' and '_' IDs. func (s *Store) DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error { if pluginID == "" { return ErrNotFound } prefix := "plugin:" + pluginID pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)` _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, actor, projectID, ref, prefix, prefix+":", prefix+":") return err } func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil { return err } return tx.Commit() } func (s *Store) deleteKnowledgeAndVectorsTx(ctx context.Context, tx *sql.Tx, pred string, args ...any) (int64, error) { if err := ctx.Err(); err != nil { return 0, err } if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { return 0, err } res, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...) if err != nil { return 0, err } return res.RowsAffected() } func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q) if err != nil { return nil, err } // CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。 if len(out) == 0 && q.Text != "" { topK := q.TopK if topK <= 0 { topK = 5 } return s.searchKnowledgeLike(owner, q, topK) } return out, nil } // searchKnowledgeSQLiteShared 共享版:FTS SQL 本身无 owner 预过滤(可见性原为 // 后置判定),直接换谓词即可;LIKE 兜底换“他人行”预过滤。 func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(pred, q) if err != nil { return nil, err } if len(out) == 0 && q.Text != "" { return s.searchKnowledgeLikeShared(requester, q, pred) } return out, nil } // searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。 func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) { return s.searchKnowledgeFTSCandidates(pred, q, true) } func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.RetrievalQuery, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } query := `SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid WHERE knowledge_fts MATCH ? ORDER BY rank` args := []any{ftsQuery(q.Text)} if bounded { query += " LIMIT ?" args = append(args, topK*2) } rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } defer rows.Close() out := []core.RetrievalHit{} for rows.Next() { h, err := scanKnowledgeHit(rows) if err != nil { return nil, err } if !pred(h.Doc) { continue } out = append(out, h) if len(out) >= topK { break } } return out, rows.Err() } func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, true) } func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } query := `SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?)` args := []any{requester, "%" + q.Text + "%", "%" + q.Text + "%"} if bounded { query += " LIMIT ?" args = append(args, topK) } rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } defer rows.Close() out := []core.RetrievalHit{} for rows.Next() { var d core.KnowledgeDoc var own, ca string if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil { return nil, err } d.Owner = own d.CreatedAt, _ = parseTime(ca) if !pred(d) { continue } out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"}) if len(out) >= topK { break } } return out, rows.Err() } func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) { rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE (owner=? OR owner='') AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`), owner, "%"+q.Text+"%", "%"+q.Text+"%", topK) if err != nil { return nil, err } defer rows.Close() out := []core.RetrievalHit{} for rows.Next() { var d core.KnowledgeDoc var own, ca string if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil { return nil, err } d.Owner = own d.CreatedAt, _ = parseTime(ca) if !knowledgeVisible(owner, q, d) { continue } out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"}) } return out, rows.Err() } // knowledgeVisible 共同过滤:租户(本人 + 共享)、项目、来源。 func knowledgeVisible(owner string, q core.RetrievalQuery, d core.KnowledgeDoc) bool { if d.Owner != "" && d.Owner != owner { return false } if q.ProjectID != "" && d.ProjectID != "" && d.ProjectID != q.ProjectID { return false } if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) { return false } return true } type rowScanner interface{ Scan(dest ...any) error } func scanKnowledgeHit(rows rowScanner) (core.RetrievalHit, error) { var d core.KnowledgeDoc var own, ca string var score float64 if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &score); err != nil { return core.RetrievalHit{}, err } d.Owner = own d.CreatedAt, _ = parseTime(ca) return core.RetrievalHit{Doc: d, Score: -score, Kind: "fulltext"}, nil } // ftsQuery 做最小安全化:按空白切词加引号,FTS5 前缀匹配。 func ftsQuery(text string) string { words := splitWords(text) if len(words) == 0 { return `""` } out := "" for i, w := range words { if i > 0 { out += " " } out += `"` + w + `"*` } return out } func splitWords(s string) []string { out := []string{} cur := []rune{} for _, r := range s { if r == ' ' || r == '\t' || r == '\n' { if len(cur) > 0 { out = append(out, string(cur)) cur = nil } continue } cur = append(cur, r) } if len(cur) > 0 { out = append(out, string(cur)) } return out } func containsStr(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false }