950 lines
25 KiB
Go
950 lines
25 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"ai-workflow-skill/packages/repo-memory-runtime/internal/documents"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
const schema = `
|
|
PRAGMA foreign_keys = ON;
|
|
|
|
CREATE TABLE IF NOT EXISTS repos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
root_path TEXT NOT NULL UNIQUE,
|
|
vcs TEXT NOT NULL DEFAULT 'git',
|
|
default_branch TEXT,
|
|
last_seen_branch TEXT,
|
|
last_seen_commit TEXT,
|
|
last_sync_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS knowledge_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
detail_md TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL,
|
|
confidence REAL NOT NULL DEFAULT 0.7,
|
|
scope_branch TEXT,
|
|
scope_module TEXT,
|
|
scope_path_prefix TEXT,
|
|
source_path TEXT,
|
|
source_line INTEGER,
|
|
verified_at TEXT,
|
|
verified_on_commit TEXT,
|
|
last_checked_at TEXT,
|
|
last_checked_commit TEXT,
|
|
stale_reason TEXT,
|
|
superseded_by INTEGER REFERENCES knowledge_entries(id),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(repo_id, kind, key, scope_branch, scope_module, scope_path_prefix)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS knowledge_dependencies (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
|
dep_type TEXT NOT NULL,
|
|
locator TEXT NOT NULL,
|
|
is_hard INTEGER NOT NULL DEFAULT 1,
|
|
baseline_hash TEXT,
|
|
note TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_entry_id
|
|
ON knowledge_dependencies(entry_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_locator
|
|
ON knowledge_dependencies(dep_type, locator);
|
|
|
|
CREATE TABLE IF NOT EXISTS knowledge_aliases (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
|
alias TEXT NOT NULL,
|
|
normalized_alias TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(entry_id, normalized_alias)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_aliases_normalized
|
|
ON knowledge_aliases(normalized_alias);
|
|
|
|
CREATE TABLE IF NOT EXISTS knowledge_links (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
from_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
|
to_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
|
relation TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_links_from
|
|
ON knowledge_links(from_entry_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_links_to
|
|
ON knowledge_links(to_entry_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS knowledge_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
|
event_type TEXT NOT NULL,
|
|
from_status TEXT,
|
|
to_status TEXT,
|
|
reason TEXT,
|
|
evidence TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_events_entry_id_created_at
|
|
ON knowledge_events(entry_id, created_at);
|
|
`
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
type RepoState struct {
|
|
RootPath string
|
|
Name string
|
|
VCS string
|
|
DefaultBranch string
|
|
LastSeenBranch string
|
|
LastSeenCommit string
|
|
}
|
|
|
|
type DependencyInput struct {
|
|
Type string
|
|
Locator string
|
|
IsHard bool
|
|
BaselineHash string
|
|
Note string
|
|
}
|
|
|
|
type EntryInput struct {
|
|
RepoRoot string
|
|
Kind string
|
|
Key string
|
|
Title string
|
|
Summary string
|
|
DetailMD string
|
|
Status string
|
|
Confidence float64
|
|
ScopeBranch string
|
|
ScopeModule string
|
|
ScopePathPrefix string
|
|
SourcePath string
|
|
SourceLine int
|
|
VerifiedAt string
|
|
VerifiedOnCommit string
|
|
LastCheckedAt string
|
|
LastCheckedCommit string
|
|
StaleReason string
|
|
Aliases []string
|
|
Dependencies []DependencyInput
|
|
}
|
|
|
|
type SearchParams struct {
|
|
Query string
|
|
RepoFilter string
|
|
Limit int
|
|
}
|
|
|
|
type SearchResult struct {
|
|
ID int64
|
|
RepoPath string
|
|
Kind string
|
|
Key string
|
|
Title string
|
|
Status string
|
|
Summary string
|
|
Snippet string
|
|
}
|
|
|
|
type ListParams struct {
|
|
RepoFilter string
|
|
Kind string
|
|
Status string
|
|
Limit int
|
|
}
|
|
|
|
type EntryListItem struct {
|
|
ID int64
|
|
RepoPath string
|
|
Kind string
|
|
Key string
|
|
Title string
|
|
Status string
|
|
Summary string
|
|
VerifiedOnCommit string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type EntryRef struct {
|
|
ID int64
|
|
RepoRoot string
|
|
Kind string
|
|
Key string
|
|
}
|
|
|
|
type EventRecord struct {
|
|
ID int64
|
|
EventType string
|
|
FromStatus string
|
|
ToStatus string
|
|
Reason string
|
|
Evidence string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type VerifyCandidate struct {
|
|
ID int64
|
|
RepoPath string
|
|
Kind string
|
|
Key string
|
|
Title string
|
|
Status string
|
|
VerifiedOnCommit string
|
|
Dependencies []DependencyInput
|
|
}
|
|
|
|
type RepoInfo struct {
|
|
Path string
|
|
Name string
|
|
EntryCount int
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
func Open(path string) (*Store, error) {
|
|
db, err := sql.Open("sqlite3", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
func (s *Store) Init(ctx context.Context) error {
|
|
_, err := s.db.ExecContext(ctx, schema)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UpsertRepo(ctx context.Context, state RepoState) (int64, error) {
|
|
now := nowUTC()
|
|
if strings.TrimSpace(state.RootPath) == "" {
|
|
return 0, fmt.Errorf("repo root path is required")
|
|
}
|
|
if strings.TrimSpace(state.Name) == "" {
|
|
state.Name = filepath.Base(state.RootPath)
|
|
}
|
|
if strings.TrimSpace(state.VCS) == "" {
|
|
state.VCS = "git"
|
|
}
|
|
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO repos(name, root_path, vcs, default_branch, last_seen_branch, last_seen_commit, last_sync_at, created_at, updated_at)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(root_path) DO UPDATE SET
|
|
name = excluded.name,
|
|
vcs = excluded.vcs,
|
|
default_branch = excluded.default_branch,
|
|
last_seen_branch = excluded.last_seen_branch,
|
|
last_seen_commit = excluded.last_seen_commit,
|
|
last_sync_at = excluded.last_sync_at,
|
|
updated_at = excluded.updated_at
|
|
`, state.Name, state.RootPath, state.VCS, nullable(state.DefaultBranch), nullable(state.LastSeenBranch), nullable(state.LastSeenCommit), now, now, now)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var repoID int64
|
|
if err := s.db.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, state.RootPath).Scan(&repoID); err != nil {
|
|
return 0, err
|
|
}
|
|
return repoID, nil
|
|
}
|
|
|
|
func (s *Store) UpsertEntry(ctx context.Context, in EntryInput) (int64, error) {
|
|
if strings.TrimSpace(in.RepoRoot) == "" {
|
|
return 0, fmt.Errorf("repo root is required")
|
|
}
|
|
if strings.TrimSpace(in.Kind) == "" {
|
|
return 0, fmt.Errorf("kind is required")
|
|
}
|
|
if strings.TrimSpace(in.Key) == "" {
|
|
return 0, fmt.Errorf("key is required")
|
|
}
|
|
if strings.TrimSpace(in.Summary) == "" {
|
|
return 0, fmt.Errorf("summary is required")
|
|
}
|
|
if in.Confidence <= 0 {
|
|
in.Confidence = 0.7
|
|
}
|
|
if strings.TrimSpace(in.Status) == "" {
|
|
in.Status = "draft"
|
|
}
|
|
if strings.TrimSpace(in.Title) == "" {
|
|
in.Title = in.Key
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var repoID int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, in.RepoRoot).Scan(&repoID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, fmt.Errorf("repo %s is not registered", in.RepoRoot)
|
|
}
|
|
return 0, err
|
|
}
|
|
|
|
now := nowUTC()
|
|
var entryID int64
|
|
var oldStatus sql.NullString
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT id, status
|
|
FROM knowledge_entries
|
|
WHERE repo_id = ?
|
|
AND kind = ?
|
|
AND key = ?
|
|
AND scope_branch IS ?
|
|
AND scope_module IS ?
|
|
AND scope_path_prefix IS ?
|
|
`, repoID, in.Kind, in.Key, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix)).Scan(&entryID, &oldStatus)
|
|
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return 0, err
|
|
}
|
|
|
|
eventType := "created"
|
|
fromStatus := ""
|
|
if err == sql.ErrNoRows {
|
|
res, execErr := tx.ExecContext(ctx, `
|
|
INSERT INTO knowledge_entries(
|
|
repo_id, kind, key, title, summary, detail_md, status, confidence,
|
|
scope_branch, scope_module, scope_path_prefix, source_path, source_line,
|
|
verified_at, verified_on_commit, last_checked_at, last_checked_commit,
|
|
stale_reason, created_at, updated_at
|
|
)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, repoID, in.Kind, in.Key, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence,
|
|
nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), nullable(in.SourcePath), nullableInt(in.SourceLine),
|
|
nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit),
|
|
nullable(in.StaleReason), now, now)
|
|
if execErr != nil {
|
|
return 0, execErr
|
|
}
|
|
entryID, err = res.LastInsertId()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
} else {
|
|
eventType = "updated"
|
|
fromStatus = oldStatus.String
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE knowledge_entries
|
|
SET title = ?, summary = ?, detail_md = ?, status = ?, confidence = ?,
|
|
scope_branch = ?, scope_module = ?, scope_path_prefix = ?,
|
|
source_path = ?, source_line = ?, verified_at = ?, verified_on_commit = ?,
|
|
last_checked_at = ?, last_checked_commit = ?, stale_reason = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence,
|
|
nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix),
|
|
nullable(in.SourcePath), nullableInt(in.SourceLine), nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit),
|
|
nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), nullable(in.StaleReason), now, entryID); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_aliases WHERE entry_id = ?`, entryID); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_dependencies WHERE entry_id = ?`, entryID); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
for _, alias := range dedupeStrings(in.Aliases) {
|
|
if strings.TrimSpace(alias) == "" {
|
|
continue
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO knowledge_aliases(entry_id, alias, normalized_alias, created_at)
|
|
VALUES(?, ?, ?, ?)
|
|
`, entryID, alias, normalizeAlias(alias), now); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
for _, dep := range in.Dependencies {
|
|
if strings.TrimSpace(dep.Type) == "" || strings.TrimSpace(dep.Locator) == "" {
|
|
continue
|
|
}
|
|
isHard := 0
|
|
if dep.IsHard {
|
|
isHard = 1
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO knowledge_dependencies(entry_id, dep_type, locator, is_hard, baseline_hash, note, created_at)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
`, entryID, dep.Type, dep.Locator, isHard, nullable(dep.BaselineHash), nullable(dep.Note), now); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
`, entryID, eventType, nullable(fromStatus), nullable(in.Status), nullable(in.StaleReason), nullable(eventEvidence(in)), now); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return entryID, tx.Commit()
|
|
}
|
|
|
|
func (s *Store) ImportDocument(ctx context.Context, doc documents.Document, repoState RepoState) error {
|
|
repoState.RootPath = doc.RepoPath
|
|
if _, err := s.UpsertRepo(ctx, repoState); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, sec := range doc.Sections {
|
|
key := buildImportedKey(doc.DocPath, sec.Heading, sec.Ordinal)
|
|
entry := EntryInput{
|
|
RepoRoot: doc.RepoPath,
|
|
Kind: classifyImportedKind(doc.Kind, sec.Heading),
|
|
Key: key,
|
|
Title: sec.Heading,
|
|
Summary: sectionSummary(sec.Body),
|
|
DetailMD: sec.Body,
|
|
Status: "confirmed",
|
|
Confidence: 0.8,
|
|
SourcePath: doc.DocPath,
|
|
VerifiedAt: nowUTC(),
|
|
VerifiedOnCommit: repoState.LastSeenCommit,
|
|
Dependencies: []DependencyInput{{
|
|
Type: "file",
|
|
Locator: doc.DocPath,
|
|
IsHard: true,
|
|
Note: "Imported from markdown knowledge source",
|
|
}},
|
|
}
|
|
if _, err := s.UpsertEntry(ctx, entry); err != nil {
|
|
return fmt.Errorf("import section %s: %w", sec.Heading, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Search(ctx context.Context, params SearchParams) ([]SearchResult, error) {
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
terms := dedupeStrings(strings.Fields(strings.ToLower(params.Query)))
|
|
if len(terms) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
query := `
|
|
SELECT DISTINCT
|
|
e.id,
|
|
r.root_path,
|
|
e.kind,
|
|
e.key,
|
|
e.title,
|
|
e.status,
|
|
e.summary,
|
|
e.detail_md
|
|
FROM knowledge_entries e
|
|
JOIN repos r ON r.id = e.repo_id
|
|
LEFT JOIN knowledge_aliases a ON a.entry_id = e.id
|
|
WHERE 1 = 1
|
|
`
|
|
args := make([]any, 0, len(terms)*5+2)
|
|
if params.RepoFilter != "" {
|
|
query += ` AND r.root_path LIKE ?`
|
|
args = append(args, "%"+params.RepoFilter+"%")
|
|
}
|
|
for _, term := range terms {
|
|
query += ` AND (
|
|
LOWER(e.key) LIKE ?
|
|
OR LOWER(e.title) LIKE ?
|
|
OR LOWER(e.summary) LIKE ?
|
|
OR LOWER(e.detail_md) LIKE ?
|
|
OR LOWER(COALESCE(a.normalized_alias, '')) LIKE ?
|
|
)`
|
|
like := "%" + term + "%"
|
|
args = append(args, like, like, like, like, like)
|
|
}
|
|
query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?`
|
|
args = append(args, limit)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []SearchResult
|
|
for rows.Next() {
|
|
var res SearchResult
|
|
var detail string
|
|
if err := rows.Scan(&res.ID, &res.RepoPath, &res.Kind, &res.Key, &res.Title, &res.Status, &res.Summary, &detail); err != nil {
|
|
return nil, err
|
|
}
|
|
res.Snippet = makeSnippet(res.Summary+"\n"+detail, terms)
|
|
results = append(results, res)
|
|
}
|
|
return results, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListRepos(ctx context.Context) ([]RepoInfo, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT r.root_path, r.name, COUNT(e.id) AS entry_count, r.updated_at
|
|
FROM repos r
|
|
LEFT JOIN knowledge_entries e ON e.repo_id = r.id
|
|
GROUP BY r.id
|
|
ORDER BY r.root_path
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var repos []RepoInfo
|
|
for rows.Next() {
|
|
var repo RepoInfo
|
|
var updated string
|
|
if err := rows.Scan(&repo.Path, &repo.Name, &repo.EntryCount, &updated); err != nil {
|
|
return nil, err
|
|
}
|
|
repo.UpdatedAt, err = time.Parse(time.RFC3339, updated)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse repo updated_at: %w", err)
|
|
}
|
|
repos = append(repos, repo)
|
|
}
|
|
return repos, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListEntries(ctx context.Context, params ListParams) ([]EntryListItem, error) {
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
query := `
|
|
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
|
|
FROM knowledge_entries e
|
|
JOIN repos r ON r.id = e.repo_id
|
|
WHERE 1 = 1
|
|
`
|
|
args := make([]any, 0, 4)
|
|
if params.RepoFilter != "" {
|
|
query += ` AND r.root_path LIKE ?`
|
|
args = append(args, "%"+params.RepoFilter+"%")
|
|
}
|
|
if params.Kind != "" {
|
|
query += ` AND e.kind = ?`
|
|
args = append(args, params.Kind)
|
|
}
|
|
if params.Status != "" {
|
|
query += ` AND e.status = ?`
|
|
args = append(args, params.Status)
|
|
}
|
|
query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?`
|
|
args = append(args, limit)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []EntryListItem
|
|
for rows.Next() {
|
|
var item EntryListItem
|
|
var updated string
|
|
if err := rows.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated); err != nil {
|
|
return nil, err
|
|
}
|
|
item.UpdatedAt, err = time.Parse(time.RFC3339, updated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ResolveEntry(ctx context.Context, ref EntryRef) (EntryListItem, error) {
|
|
if ref.ID > 0 {
|
|
return s.getEntryByID(ctx, ref.ID)
|
|
}
|
|
if strings.TrimSpace(ref.RepoRoot) == "" || strings.TrimSpace(ref.Kind) == "" || strings.TrimSpace(ref.Key) == "" {
|
|
return EntryListItem{}, fmt.Errorf("either id or repo+kind+key is required")
|
|
}
|
|
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
|
|
FROM knowledge_entries e
|
|
JOIN repos r ON r.id = e.repo_id
|
|
WHERE r.root_path = ? AND e.kind = ? AND e.key = ?
|
|
ORDER BY e.updated_at DESC
|
|
LIMIT 1
|
|
`, ref.RepoRoot, ref.Kind, ref.Key)
|
|
return scanEntryListItem(row)
|
|
}
|
|
|
|
func (s *Store) ListEvents(ctx context.Context, entryID int64, limit int) ([]EventRecord, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, event_type, COALESCE(from_status, ''), COALESCE(to_status, ''), COALESCE(reason, ''), COALESCE(evidence, ''), created_at
|
|
FROM knowledge_events
|
|
WHERE entry_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`, entryID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var events []EventRecord
|
|
for rows.Next() {
|
|
var ev EventRecord
|
|
var created string
|
|
if err := rows.Scan(&ev.ID, &ev.EventType, &ev.FromStatus, &ev.ToStatus, &ev.Reason, &ev.Evidence, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
ev.CreatedAt, err = time.Parse(time.RFC3339, created)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
return events, rows.Err()
|
|
}
|
|
|
|
func (s *Store) AddLink(ctx context.Context, fromEntryID, toEntryID int64, relation string) error {
|
|
if fromEntryID <= 0 || toEntryID <= 0 {
|
|
return fmt.Errorf("both entry ids are required")
|
|
}
|
|
if strings.TrimSpace(relation) == "" {
|
|
return fmt.Errorf("relation is required")
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO knowledge_links(from_entry_id, to_entry_id, relation, created_at)
|
|
VALUES(?, ?, ?, ?)
|
|
`, fromEntryID, toEntryID, relation, nowUTC())
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]VerifyCandidate, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, COALESCE(e.verified_on_commit, '')
|
|
FROM knowledge_entries e
|
|
JOIN repos r ON r.id = e.repo_id
|
|
WHERE r.root_path = ? AND e.status IN ('confirmed', 'needs_review')
|
|
ORDER BY e.kind, e.key
|
|
`, repoRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var candidates []VerifyCandidate
|
|
for rows.Next() {
|
|
var c VerifyCandidate
|
|
if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
candidates = append(candidates, c)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range candidates {
|
|
deps, err := s.listDependencies(ctx, candidates[i].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates[i].Dependencies = deps
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error {
|
|
item, err := s.getEntryByID(ctx, entryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := nowUTC()
|
|
eventType := ""
|
|
switch {
|
|
case item.Status != nextStatus && nextStatus == "needs_review":
|
|
eventType = "downgraded"
|
|
case item.Status != nextStatus && nextStatus == "stale":
|
|
eventType = "marked_stale"
|
|
case item.Status != nextStatus:
|
|
eventType = "updated"
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE knowledge_entries
|
|
SET status = ?, stale_reason = ?, last_checked_at = ?, last_checked_commit = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`, nextStatus, nullable(reason), now, nullable(currentCommit), now, entryID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if eventType != "" {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at)
|
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
`, entryID, eventType, item.Status, nextStatus, nullable(reason), nil, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func normalizeAlias(alias string) string {
|
|
alias = strings.ToLower(strings.TrimSpace(alias))
|
|
alias = strings.ReplaceAll(alias, "_", " ")
|
|
alias = strings.ReplaceAll(alias, "-", " ")
|
|
return strings.Join(strings.Fields(alias), " ")
|
|
}
|
|
|
|
func buildImportedKey(docPath, heading string, ordinal int) string {
|
|
base := strings.TrimSuffix(filepath.Base(docPath), filepath.Ext(docPath))
|
|
if normalized := slug(heading); normalized != "" {
|
|
return base + ":" + normalized
|
|
}
|
|
return fmt.Sprintf("%s:%d", base, ordinal)
|
|
}
|
|
|
|
func classifyImportedKind(docKind, heading string) string {
|
|
lowerDoc := strings.ToLower(docKind)
|
|
lowerHeading := strings.ToLower(heading)
|
|
|
|
switch lowerDoc {
|
|
case "glossary":
|
|
return "term"
|
|
case "playbooks":
|
|
return "entry"
|
|
case "repo-memory", "repo-brief":
|
|
switch {
|
|
case strings.Contains(lowerHeading, "module"):
|
|
return "module"
|
|
case strings.Contains(lowerHeading, "chain"):
|
|
return "chain"
|
|
case strings.Contains(lowerHeading, "danger"):
|
|
return "danger"
|
|
case strings.Contains(lowerHeading, "anchor"), strings.Contains(lowerHeading, "entry"):
|
|
return "entry"
|
|
case strings.Contains(lowerHeading, "command"), strings.Contains(lowerHeading, "verify"):
|
|
return "command"
|
|
default:
|
|
return "decision"
|
|
}
|
|
default:
|
|
return "decision"
|
|
}
|
|
}
|
|
|
|
func sectionSummary(body string) string {
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
return "Imported knowledge section"
|
|
}
|
|
body = strings.ReplaceAll(body, "\n", " ")
|
|
body = strings.Join(strings.Fields(body), " ")
|
|
runes := []rune(body)
|
|
if len(runes) > 160 {
|
|
return string(runes[:160]) + " …"
|
|
}
|
|
return body
|
|
}
|
|
|
|
func makeSnippet(body string, terms []string) string {
|
|
body = strings.TrimSpace(strings.ReplaceAll(body, "\n", " "))
|
|
if body == "" {
|
|
return ""
|
|
}
|
|
|
|
lower := strings.ToLower(body)
|
|
pos := -1
|
|
match := ""
|
|
for _, term := range terms {
|
|
idx := strings.Index(lower, term)
|
|
if idx >= 0 && (pos == -1 || idx < pos) {
|
|
pos = idx
|
|
match = term
|
|
}
|
|
}
|
|
if pos == -1 {
|
|
if len(body) > 160 {
|
|
return body[:160] + " …"
|
|
}
|
|
return body
|
|
}
|
|
|
|
start := pos - 50
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := pos + len(match) + 80
|
|
if end > len(body) {
|
|
end = len(body)
|
|
}
|
|
|
|
snippet := strings.TrimSpace(body[start:end])
|
|
if start > 0 {
|
|
snippet = "… " + snippet
|
|
}
|
|
if end < len(body) {
|
|
snippet += " …"
|
|
}
|
|
return snippet
|
|
}
|
|
|
|
func eventEvidence(in EntryInput) string {
|
|
payload := map[string]any{
|
|
"source_path": in.SourcePath,
|
|
"source_line": in.SourceLine,
|
|
"dependencies": in.Dependencies,
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func nowUTC() string {
|
|
return time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
func nullable(value string) any {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func nullableInt(value int) any {
|
|
if value <= 0 {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func slug(value string) string {
|
|
value = normalizeAlias(value)
|
|
return strings.ReplaceAll(value, " ", "-")
|
|
}
|
|
|
|
func dedupeStrings(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
var out []string
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
key := trimmed
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, trimmed)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) getEntryByID(ctx context.Context, id int64) (EntryListItem, error) {
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at
|
|
FROM knowledge_entries e
|
|
JOIN repos r ON r.id = e.repo_id
|
|
WHERE e.id = ?
|
|
`, id)
|
|
return scanEntryListItem(row)
|
|
}
|
|
|
|
func scanEntryListItem(row *sql.Row) (EntryListItem, error) {
|
|
var item EntryListItem
|
|
var updated string
|
|
err := row.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated)
|
|
if err != nil {
|
|
return EntryListItem{}, err
|
|
}
|
|
item.UpdatedAt, err = time.Parse(time.RFC3339, updated)
|
|
if err != nil {
|
|
return EntryListItem{}, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Store) listDependencies(ctx context.Context, entryID int64) ([]DependencyInput, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT dep_type, locator, is_hard, COALESCE(baseline_hash, ''), COALESCE(note, '')
|
|
FROM knowledge_dependencies
|
|
WHERE entry_id = ?
|
|
ORDER BY id
|
|
`, entryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var deps []DependencyInput
|
|
for rows.Next() {
|
|
var dep DependencyInput
|
|
var hard int
|
|
if err := rows.Scan(&dep.Type, &dep.Locator, &hard, &dep.BaselineHash, &dep.Note); err != nil {
|
|
return nil, err
|
|
}
|
|
dep.IsHard = hard == 1
|
|
deps = append(deps, dep)
|
|
}
|
|
return deps, rows.Err()
|
|
}
|