Merge remote-tracking branch 'origin/main' into claude/coolify-oikos-comparison-d8c2d3
This commit is contained in:
@@ -50,6 +50,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ($1 = '' OR ke.source = $1)
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at DESC
|
||||
LIMIT $2`, source, limit)
|
||||
if err != nil {
|
||||
@@ -82,6 +83,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
|
||||
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
||||
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
GROUP BY e.type`)
|
||||
if err == nil {
|
||||
defer srows.Close()
|
||||
@@ -128,15 +130,19 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var title, content, source string
|
||||
var title, content, source, editedBy string
|
||||
var tags []string
|
||||
var updatedAt string
|
||||
var revisions int
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), COALESCE(ke.edited_by,''),
|
||||
ke.tags, ke.updated_at::text,
|
||||
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
||||
Scan(&title, &content, &source, &tags, &updatedAt)
|
||||
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||
AND ke.deleted_at IS NULL`, idOrSlug).
|
||||
Scan(&title, &content, &source, &editedBy, &tags, &updatedAt, &revisions)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
||||
return
|
||||
@@ -150,8 +156,10 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
||||
"title": title,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"edited_by": editedBy,
|
||||
"tags": tags,
|
||||
"updated_at": updatedAt,
|
||||
"revisions": revisions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +177,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY rank DESC
|
||||
LIMIT $2`,
|
||||
q, limit)
|
||||
@@ -232,6 +241,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
||||
WHERE target.slug = $1
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
AND ke.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||
FROM knowledge_entities ke
|
||||
@@ -242,6 +252,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.type = 'procedure-for'
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY 2`,
|
||||
entitySlug)
|
||||
if err != nil {
|
||||
|
||||
544
internal/httpapi/knowledge_drift.go
Normal file
544
internal/httpapi/knowledge_drift.go
Normal file
@@ -0,0 +1,544 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Drift tooling for the knowledge base — the maintenance half of the wiki.
|
||||
//
|
||||
// These endpoints exist because the knowledge base measurably rots on its
|
||||
// own. Two failure modes are already present in live data:
|
||||
//
|
||||
// - **Duplicate pileup.** upsert_knowledge keys on exact title, so a note
|
||||
// titled "rclone backup live inspection — 2026-07-15 10:08 UTC" and one
|
||||
// titled "... 11:18 UTC" are different notes. A single day of agent
|
||||
// activity produced eight near-identical investigations that should have
|
||||
// been one living page. Nothing surfaced that, so it kept happening.
|
||||
// - **Tag drift.** `oom` and `OOM` were separate tags; so were `422` and
|
||||
// `proton-422`. Each split halves the usefulness of tag navigation, and
|
||||
// neither is visible from any single note.
|
||||
//
|
||||
// normalizeTags (knowledge_write.go) stops new casing splits at the door;
|
||||
// these endpoints clean up what's already there and make the rot visible.
|
||||
|
||||
// serveKnowledgeTags returns the tag index: every tag with its usage count,
|
||||
// plus the distinct casings actually stored. `variants` is the interesting
|
||||
// column — it's how the operator discovers that `oom` and `OOM` are the same
|
||||
// idea filed twice, which no individual note reveals.
|
||||
func (s *Server) serveKnowledgeTags(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT lower(tag) AS norm,
|
||||
count(*) AS uses,
|
||||
array_agg(DISTINCT tag ORDER BY tag) AS variants
|
||||
FROM knowledge_entities ke, unnest(ke.tags) AS tag
|
||||
WHERE ke.deleted_at IS NULL
|
||||
GROUP BY lower(tag)
|
||||
ORDER BY uses DESC, norm`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type tagRow struct {
|
||||
Tag string `json:"tag"`
|
||||
Uses int `json:"uses"`
|
||||
Variants []string `json:"variants"`
|
||||
// True when the same tag is stored under more than one casing —
|
||||
// the UI badges these as needing a normalize.
|
||||
Split bool `json:"split"`
|
||||
}
|
||||
items := []tagRow{}
|
||||
for rows.Next() {
|
||||
var t tagRow
|
||||
if err := rows.Scan(&t.Tag, &t.Uses, &t.Variants); err != nil {
|
||||
slog.Error("httpapi: knowledge/tags row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
t.Split = len(t.Variants) > 1
|
||||
items = append(items, t)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// serveRenameKnowledgeTag rewrites one or more tags to a single target across
|
||||
// every live note — the merge/rename/normalize action behind the tag manager.
|
||||
// Passing several `from` values into one `to` is the merge case
|
||||
// (`{"from":["422","proton-422"],"to":"proton-422"}`); passing one is a plain
|
||||
// rename; passing the mixed-case variants is the normalize case.
|
||||
func (s *Server) serveRenameKnowledgeTag(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body struct {
|
||||
From []string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
to := strings.ToLower(strings.TrimSpace(body.To))
|
||||
from := []string{}
|
||||
for _, f := range body.From {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
from = append(from, f)
|
||||
}
|
||||
}
|
||||
if to == "" || len(from) == 0 {
|
||||
writeProblem(w, req, http.StatusBadRequest, "from and to are required", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Rebuild each affected note's tag array: map every `from` member to
|
||||
// `to`, leave everything else alone, then de-duplicate. The dedupe
|
||||
// matters for the merge case — a note tagged both `422` and
|
||||
// `proton-422` would otherwise end up with `proton-422` twice.
|
||||
//
|
||||
// This is a plain UPDATE on knowledge_entities, so trg_knowledge_revision
|
||||
// fires and every affected note gets a revision. A tag merge across 17
|
||||
// notes is exactly the kind of bulk edit worth being able to inspect
|
||||
// afterwards.
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities ke
|
||||
SET tags = sub.new_tags, updated_at = now()
|
||||
FROM (
|
||||
SELECT k.entity_id,
|
||||
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
|
||||
FROM unnest(k.tags) AS t) AS new_tags
|
||||
FROM knowledge_entities k
|
||||
WHERE k.deleted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
|
||||
) AS sub
|
||||
WHERE ke.entity_id = sub.entity_id`,
|
||||
lowerAll(from), to)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "rename failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
slog.Info("knowledge tags renamed", "from", from, "to", to,
|
||||
"notes", tag.RowsAffected(), "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true, "notes_updated": tag.RowsAffected()})
|
||||
}
|
||||
|
||||
// serveKnowledgeDuplicates clusters notes whose titles are near-identical.
|
||||
//
|
||||
// Pairwise trigram similarity is computed in SQL (indexed, and the whole
|
||||
// point of pulling in pg_trgm); the grouping is done here in Go. Returning
|
||||
// clusters rather than pairs matters for the real data: the rclone pileup
|
||||
// produces dozens of pairs, which is unreadable, versus one cluster, which
|
||||
// is the actionable unit.
|
||||
//
|
||||
// The grouping uses **complete linkage** — a note joins a cluster only if it
|
||||
// is similar to every member already in it. The obvious implementation
|
||||
// (union-find over the pairs) is single linkage, and on this data it chains
|
||||
// badly: "A~B, B~C" merged notes that were not remotely alike, collapsing
|
||||
// fifteen distinct backup events into one unusable blob. Requiring mutual
|
||||
// similarity keeps clusters tight enough to act on.
|
||||
//
|
||||
// Even so, these are *candidates for review*, never a verdict. The five
|
||||
// "Lifecycle: <verb> a node" runbooks are mutually similar by title and are
|
||||
// five deliberately distinct documents — no threshold distinguishes them
|
||||
// from a genuine duplicate, so merging stays a manual, previewed action.
|
||||
func (s *Server) serveKnowledgeDuplicates(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
// 0.6, tuned against the live data: at 0.45 the "Lifecycle: <verb> a
|
||||
// node" runbooks (five deliberately distinct documents that happen to
|
||||
// share a naming template) formed a false-positive cluster; 0.6 clears
|
||||
// that down to a single borderline pair while keeping every genuine
|
||||
// duplicate cluster (the rclone/apt-audit/uptime pileups) intact.
|
||||
// Tunable per request — the UI exposes this as the review net widens.
|
||||
threshold := 0.6
|
||||
if t := req.URL.Query().Get("threshold"); t != "" {
|
||||
if v, err := strconv.ParseFloat(t, 64); err == nil && v > 0 && v <= 1 {
|
||||
threshold = v
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT a.slug, b.slug, similarity(ka.title, kb.title) AS sim
|
||||
FROM knowledge_entities ka
|
||||
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
|
||||
JOIN entities a ON a.id = ka.entity_id
|
||||
JOIN entities b ON b.id = kb.entity_id
|
||||
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
|
||||
AND similarity(ka.title, kb.title) > $1
|
||||
ORDER BY sim DESC`, threshold)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pair struct {
|
||||
A, B string
|
||||
Sim float64
|
||||
}
|
||||
pairs := []pair{}
|
||||
for rows.Next() {
|
||||
var p pair
|
||||
if err := rows.Scan(&p.A, &p.B, &p.Sim); err != nil {
|
||||
slog.Error("httpapi: knowledge/duplicates row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, p)
|
||||
}
|
||||
|
||||
// Complete-linkage grouping. `pairs` arrives sorted by similarity
|
||||
// descending, so each new cluster is seeded from the strongest remaining
|
||||
// pair and then only grows with notes that are similar to *everything*
|
||||
// already inside it.
|
||||
sim := make(map[string]float64, len(pairs)*2)
|
||||
key := func(a, b string) string {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return a + "\x00" + b
|
||||
}
|
||||
for _, p := range pairs {
|
||||
sim[key(p.A, p.B)] = p.Sim
|
||||
}
|
||||
linked := func(a, b string) bool { return sim[key(a, b)] > 0 }
|
||||
|
||||
assigned := map[string]bool{}
|
||||
type rawCluster struct {
|
||||
members []string
|
||||
top float64
|
||||
}
|
||||
raw := []rawCluster{}
|
||||
|
||||
for _, p := range pairs {
|
||||
if assigned[p.A] || assigned[p.B] {
|
||||
continue
|
||||
}
|
||||
c := rawCluster{members: []string{p.A, p.B}, top: p.Sim}
|
||||
assigned[p.A], assigned[p.B] = true, true
|
||||
|
||||
// Sweep the remaining pairs for candidates that connect to every
|
||||
// current member. Repeat until a full pass adds nothing, since
|
||||
// admitting one member can qualify another.
|
||||
for grew := true; grew; {
|
||||
grew = false
|
||||
for _, q := range pairs {
|
||||
for _, cand := range []string{q.A, q.B} {
|
||||
if assigned[cand] {
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for _, m := range c.members {
|
||||
if !linked(cand, m) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
c.members = append(c.members, cand)
|
||||
assigned[cand] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
raw = append(raw, c)
|
||||
}
|
||||
|
||||
groups := map[string][]string{}
|
||||
best := map[string]float64{}
|
||||
for _, c := range raw {
|
||||
root := c.members[0]
|
||||
groups[root] = c.members
|
||||
best[root] = c.top
|
||||
}
|
||||
|
||||
// Re-fetch display detail for the clustered slugs only.
|
||||
type member struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Size int `json:"size"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
}
|
||||
detail := map[string]member{}
|
||||
if len(groups) > 0 {
|
||||
all := []string{}
|
||||
for _, g := range groups {
|
||||
all = append(all, g...)
|
||||
}
|
||||
drows, derr := s.pool.Query(ctx, `
|
||||
SELECT e.slug, ke.title, e.type, length(ke.content),
|
||||
ke.updated_at::text, COALESCE(ke.edited_by,'')
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = ANY($1) AND ke.deleted_at IS NULL`, all)
|
||||
if derr != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "detail query failed", derr.Error())
|
||||
return
|
||||
}
|
||||
defer drows.Close()
|
||||
for drows.Next() {
|
||||
var m member
|
||||
if err := drows.Scan(&m.Slug, &m.Title, &m.Kind, &m.Size, &m.UpdatedAt, &m.EditedBy); err != nil {
|
||||
slog.Error("httpapi: knowledge/duplicates detail scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
detail[m.Slug] = m
|
||||
}
|
||||
}
|
||||
|
||||
type cluster struct {
|
||||
Members []member `json:"members"`
|
||||
TopSim float64 `json:"top_similarity"`
|
||||
TotalSize int `json:"total_size"`
|
||||
}
|
||||
out := []cluster{}
|
||||
for root, slugs := range groups {
|
||||
c := cluster{TopSim: best[root]}
|
||||
for _, sl := range slugs {
|
||||
if m, ok := detail[sl]; ok {
|
||||
c.Members = append(c.Members, m)
|
||||
c.TotalSize += m.Size
|
||||
}
|
||||
}
|
||||
if len(c.Members) < 2 {
|
||||
continue
|
||||
}
|
||||
// Newest first inside a cluster — the most recent note is usually
|
||||
// the one worth keeping as the merge target.
|
||||
sort.Slice(c.Members, func(i, j int) bool {
|
||||
return c.Members[i].UpdatedAt > c.Members[j].UpdatedAt
|
||||
})
|
||||
out = append(out, c)
|
||||
}
|
||||
// Biggest clusters first: an eight-note pileup deserves attention before
|
||||
// a two-note coincidence.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if len(out[i].Members) != len(out[j].Members) {
|
||||
return len(out[i].Members) > len(out[j].Members)
|
||||
}
|
||||
return out[i].TopSim > out[j].TopSim
|
||||
})
|
||||
|
||||
writeJSON(w, map[string]any{"clusters": out, "threshold": threshold})
|
||||
}
|
||||
|
||||
// serveKnowledgeOrphans surfaces notes that have fallen out of every
|
||||
// navigation path — the ones that are technically present but effectively
|
||||
// unreachable, and so quietly stop being maintained.
|
||||
//
|
||||
// Three independent reasons, reported per note (a note can have several):
|
||||
// - untagged: invisible to tag navigation
|
||||
// - unlinked: not `about` any entity, so it never appears on a machine's page
|
||||
// - stale: untouched for 90+ days
|
||||
func (s *Server) serveKnowledgeOrphans(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
staleDays := 90
|
||||
if d := req.URL.Query().Get("stale_days"); d != "" {
|
||||
if v, err := strconv.Atoi(d); err == nil && v > 0 && v <= 3650 {
|
||||
staleDays = v
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''),
|
||||
ke.updated_at::text,
|
||||
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM relationships r
|
||||
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
) AS unlinked,
|
||||
(ke.updated_at < now() - interval '%d days') AS stale
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at ASC`, staleDays))
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type orphan struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
items := []orphan{}
|
||||
counts := map[string]int{"untagged": 0, "unlinked": 0, "stale": 0}
|
||||
for rows.Next() {
|
||||
var o orphan
|
||||
var untagged, unlinked, stale bool
|
||||
if err := rows.Scan(&o.Slug, &o.Title, &o.Kind, &o.EditedBy, &o.UpdatedAt,
|
||||
&untagged, &unlinked, &stale); err != nil {
|
||||
slog.Error("httpapi: knowledge/orphans row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
o.Reasons = []string{}
|
||||
if untagged {
|
||||
o.Reasons = append(o.Reasons, "untagged")
|
||||
counts["untagged"]++
|
||||
}
|
||||
if unlinked {
|
||||
o.Reasons = append(o.Reasons, "unlinked")
|
||||
counts["unlinked"]++
|
||||
}
|
||||
if stale {
|
||||
o.Reasons = append(o.Reasons, "stale")
|
||||
counts["stale"]++
|
||||
}
|
||||
if len(o.Reasons) > 0 {
|
||||
items = append(items, o)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"items": items,
|
||||
"counts": counts,
|
||||
"stale_days": staleDays,
|
||||
})
|
||||
}
|
||||
|
||||
// serveMergeKnowledge folds several notes into one: each source's body is
|
||||
// appended to the target under a provenance heading, the union of all tags is
|
||||
// kept, and the sources are soft-deleted.
|
||||
//
|
||||
// Append rather than discard, and soft-delete rather than hard: a merge is a
|
||||
// judgement call made from a similarity score, and the operator needs to be
|
||||
// able to walk it back. The target's pre-merge state is captured by the
|
||||
// revision trigger, so the merge itself is undoable from the History tab.
|
||||
func (s *Server) serveMergeKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
Sources []string `json:"sources"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Target) == "" || len(body.Sources) == 0 {
|
||||
writeProblem(w, req, http.StatusBadRequest, "target and sources are required", "")
|
||||
return
|
||||
}
|
||||
|
||||
targetID, err := s.resolveKnowledgeEntity(ctx, body.Target)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "target note not found", body.Target)
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var merged []string
|
||||
var appended strings.Builder
|
||||
tagSet := map[string]bool{}
|
||||
|
||||
for _, srcSlug := range body.Sources {
|
||||
if srcSlug == body.Target {
|
||||
continue // merging a note into itself would duplicate its body
|
||||
}
|
||||
var srcTitle, srcContent, srcUpdated string
|
||||
var srcTags []string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
||||
srcSlug).Scan(&srcTitle, &srcContent, &srcTags, &srcUpdated)
|
||||
if err != nil {
|
||||
slog.Warn("knowledge merge: source not found, skipping", "slug", srcSlug)
|
||||
continue
|
||||
}
|
||||
appended.WriteString("\n\n---\n\n## Merged: ")
|
||||
appended.WriteString(srcTitle)
|
||||
appended.WriteString("\n\n*Originally ")
|
||||
appended.WriteString(srcSlug)
|
||||
appended.WriteString(", last updated ")
|
||||
appended.WriteString(srcUpdated)
|
||||
appended.WriteString("*\n\n")
|
||||
appended.WriteString(srcContent)
|
||||
for _, t := range srcTags {
|
||||
tagSet[strings.ToLower(strings.TrimSpace(t))] = true
|
||||
}
|
||||
merged = append(merged, srcSlug)
|
||||
}
|
||||
|
||||
if len(merged) == 0 {
|
||||
writeProblem(w, req, http.StatusBadRequest, "no valid source notes to merge", "")
|
||||
return
|
||||
}
|
||||
|
||||
extraTags := make([]string, 0, len(tagSet))
|
||||
for t := range tagSet {
|
||||
if t != "" {
|
||||
extraTags = append(extraTags, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(extraTags)
|
||||
|
||||
// The array concat + DISTINCT keeps the target's own tags first and adds
|
||||
// only what the sources contribute.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities
|
||||
SET content = content || $2,
|
||||
tags = ARRAY(SELECT DISTINCT unnest(COALESCE(tags,'{}') || $3::text[])),
|
||||
edited_by = $4,
|
||||
updated_at = now()
|
||||
WHERE entity_id = $1`,
|
||||
targetID, appended.String(), extraTags, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "merge write failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, srcSlug := range merged {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities ke
|
||||
SET deleted_at = now(), edited_by = $2
|
||||
FROM entities e
|
||||
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
|
||||
srcSlug, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "source delete failed", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge merged", "target", body.Target, "sources", merged, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true, "merged": merged, "tags_added": extraTags})
|
||||
}
|
||||
|
||||
// lowerAll is the case-folding helper the tag queries compare against.
|
||||
func lowerAll(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
659
internal/httpapi/knowledge_write.go
Normal file
659
internal/httpapi/knowledge_write.go
Normal file
@@ -0,0 +1,659 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Operator-facing write path for the knowledge base. Until this file, the
|
||||
// only way anything reached knowledge_entities was the MCP tool
|
||||
// upsert_knowledge (internal/mcp/server.go) — an agent-only surface. The web
|
||||
// UI could search and read but never create, correct, or remove a note, so
|
||||
// the operator's own knowledge had nowhere to go and an agent mistake had no
|
||||
// fix short of psql.
|
||||
//
|
||||
// All routes here are non-OpenAPI custom routes, consistent with the existing
|
||||
// knowledge read routes (see the carve-out block in server.go): they trade in
|
||||
// raw markdown and ad-hoc aggregates rather than generated schema types.
|
||||
//
|
||||
// Deletion is soft (deleted_at) — see migrations/022_knowledge_revisions.up.sql
|
||||
// for why — so every read path in this file filters on `ke.deleted_at IS NULL`.
|
||||
|
||||
// knowledgeSlugSegmentRe strips a title down to a single slug segment.
|
||||
// Mirrors knowledgeSlugRe in internal/mcp/server.go; duplicated rather than
|
||||
// exported across the package boundary because the two callers namespace
|
||||
// their output differently (see knowledgeSlugFor).
|
||||
var knowledgeSlugSegmentRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// knowledgeSlugFor builds `<kind>:<folder>/<title-slug>`. The MCP tool's
|
||||
// equivalent hardcodes the `nomos/` folder; operator-created notes need to
|
||||
// land somewhere else so the navigator tree can tell at a glance who wrote
|
||||
// what, and so an operator note can never collide with an agent note that
|
||||
// happens to share a title.
|
||||
func knowledgeSlugFor(kind, folder, title string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = knowledgeSlugSegmentRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
s = "note"
|
||||
}
|
||||
if len(s) > 80 {
|
||||
s = s[:80]
|
||||
}
|
||||
folder = strings.Trim(strings.ToLower(strings.TrimSpace(folder)), "/")
|
||||
folder = knowledgeSlugSegmentRe.ReplaceAllString(folder, "-")
|
||||
folder = strings.Trim(folder, "-")
|
||||
if folder == "" {
|
||||
folder = "operator"
|
||||
}
|
||||
return kind + ":" + folder + "/" + s
|
||||
}
|
||||
|
||||
// validKnowledgeKind mirrors the three entity types that knowledge_entities
|
||||
// rows are allowed to hang off (see upsert_knowledge's own check).
|
||||
func validKnowledgeKind(kind string) bool {
|
||||
switch kind {
|
||||
case "document", "investigation", "runbook":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveKnowledgeEntity maps an id-or-slug path segment to the entity id of
|
||||
// a live (non-deleted) knowledge note. Returns pgx.ErrNoRows when there's no
|
||||
// such note, which callers turn into a 404.
|
||||
func (s *Server) resolveKnowledgeEntity(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||
AND ke.deleted_at IS NULL`, idOrSlug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// resolveKnowledgeEntityAny is resolveKnowledgeEntity without the
|
||||
// deleted_at filter — for the one read path (revisions) that must still work
|
||||
// on a deleted note. The whole point of soft-delete is that a note's history
|
||||
// stays inspectable after removal (e.g. to confirm what was lost before
|
||||
// restoring it); requiring the note to be live first would defeat that.
|
||||
func (s *Server) resolveKnowledgeEntityAny(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// pathParam pulls a chi URL param and percent-decodes it. Knowledge slugs
|
||||
// contain both ':' and '/' (e.g. "document:containers/101-jellyfin"), so they
|
||||
// reach the handler still encoded — chi.URLParam does no decoding of its own
|
||||
// on manually-registered routes (unlike the OpenAPI-generated ones, which
|
||||
// decode via runtime.BindStyledParameterWithOptions).
|
||||
func pathParam(req *http.Request, name string) (string, error) {
|
||||
return url.PathUnescape(chi.URLParam(req, name))
|
||||
}
|
||||
|
||||
// serveKnowledgeList returns every live note without its body — the backing
|
||||
// data for the wiki navigator tree. Distinct from /knowledge/recent, which
|
||||
// caps at 200 and exists to answer "what changed lately" for the stats view:
|
||||
// the tree needs the complete set, and needs the linked-entity slugs so it
|
||||
// can offer a group-by-entity arrangement without N+1 fetches.
|
||||
//
|
||||
// Body text is deliberately excluded — with ~100 notes averaging ~1 KB the
|
||||
// full payload would be ~100 KB per app open, to render a list that shows
|
||||
// only titles.
|
||||
func (s *Server) serveKnowledgeList(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
Tags []string `json:"tags"`
|
||||
About []string `json:"about"`
|
||||
Size int `json:"size"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Revisions int `json:"revisions"`
|
||||
}
|
||||
|
||||
// The `about` aggregate mirrors GetEntityKnowledge's first UNION branch
|
||||
// (documents/about edges) — the 'procedure-for' branch is left out here
|
||||
// because it joins against entity *types* rather than entities and can't
|
||||
// produce a per-note slug list.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.id::text, e.slug, ke.title, e.type, COALESCE(ke.source,''),
|
||||
COALESCE(ke.edited_by,''), COALESCE(ke.tags, '{}'),
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT t.slug)
|
||||
FROM relationships r
|
||||
JOIN entities t ON t.id = r.target_id
|
||||
WHERE r.source_id = ke.entity_id
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
), '{}'),
|
||||
length(ke.content),
|
||||
ke.updated_at::text, ke.created_at::text,
|
||||
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at DESC`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Kind, &it.Source,
|
||||
&it.EditedBy, &it.Tags, &it.About, &it.Size,
|
||||
&it.UpdatedAt, &it.CreatedAt, &it.Revisions); err != nil {
|
||||
slog.Error("httpapi: knowledge/list row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// serveKnowledgeTrash lists soft-deleted notes — the counterpart to
|
||||
// serveKnowledgeList, and what the "restore" affordance in the UI browses.
|
||||
// Without this, a deleted note is invisible from every list endpoint
|
||||
// (correctly — they all filter deleted_at) with no way to even discover it
|
||||
// exists to restore.
|
||||
func (s *Server) serveKnowledgeTrash(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.deleted_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NOT NULL
|
||||
ORDER BY ke.deleted_at DESC`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
DeletedBy string `json:"deleted_by"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
}
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &it.DeletedBy, &it.DeletedAt); err != nil {
|
||||
slog.Error("httpapi: knowledge/trash row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// knowledgeWriteBody is the shared request shape for create and update.
|
||||
// Every field is a pointer so update can distinguish "not supplied" (leave
|
||||
// alone) from "supplied empty" (clear it) — a PUT that only changes tags
|
||||
// must not blank the body.
|
||||
type knowledgeWriteBody struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Kind *string `json:"kind"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Folder *string `json:"folder"`
|
||||
About *[]string `json:"about"`
|
||||
}
|
||||
|
||||
// serveCreateKnowledge creates a note plus its backing entity, and links it
|
||||
// to whatever entities it's about.
|
||||
func (s *Server) serveCreateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body knowledgeWriteBody
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(deref(body.Title))
|
||||
content := strings.TrimSpace(deref(body.Content))
|
||||
if title == "" || content == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "title and content are required", "")
|
||||
return
|
||||
}
|
||||
kind := deref(body.Kind)
|
||||
if kind == "" {
|
||||
kind = "document"
|
||||
}
|
||||
if !validKnowledgeKind(kind) {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid kind",
|
||||
"kind must be document, investigation, or runbook")
|
||||
return
|
||||
}
|
||||
tags := normalizeTags(derefSlice(body.Tags))
|
||||
slug := knowledgeSlugFor(kind, deref(body.Folder), title)
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
docID, _ := uuid.NewV7()
|
||||
// ON CONFLICT covers the soft-deleted case: the entity row survives a
|
||||
// delete, so recreating a note under the same slug must reuse it rather
|
||||
// than fail the unique constraint.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, attributes)
|
||||
VALUES ($1, $2, $3, $4, '{}')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "create entity failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse to silently overwrite an existing LIVE note — upsert_knowledge
|
||||
// (the MCP tool) deliberately upserts by title (the agent re-records the
|
||||
// same finding as it learns more), but an operator hitting "create" with
|
||||
// a colliding title almost certainly means to write something new.
|
||||
//
|
||||
// The `WHERE knowledge_entities.deleted_at IS NOT NULL` guard makes this
|
||||
// check atomic with the write, rather than a separate SELECT before it:
|
||||
// a plain pre-check has a TOCTOU race where two concurrent creates of
|
||||
// the same title can both pass the check and then both proceed to
|
||||
// INSERT ON CONFLICT DO UPDATE, silently clobbering each other. Here,
|
||||
// the UPDATE branch only actually applies when the conflicting row is
|
||||
// soft-deleted (a legitimate "resurrect" case). When it isn't, the row
|
||||
// is left untouched, RETURNING yields no row, and pgx.ErrNoRows below
|
||||
// becomes the 409 — the collision can never be missed, no matter how
|
||||
// the two writers interleave.
|
||||
var wroteID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO knowledge_entities
|
||||
(entity_id, title, content, source, tags, edited_by, updated_at, deleted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $4, now(), NULL)
|
||||
ON CONFLICT (entity_id) DO UPDATE
|
||||
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
||||
tags = EXCLUDED.tags, edited_by = EXCLUDED.edited_by,
|
||||
updated_at = now(), deleted_at = NULL
|
||||
WHERE knowledge_entities.deleted_at IS NOT NULL
|
||||
RETURNING entity_id`,
|
||||
docID, title, content, actorLabel, tags).Scan(&wroteID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeProblem(w, req, http.StatusConflict, "a note with this title already exists", slug)
|
||||
return
|
||||
} else if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "write knowledge failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
linked := s.linkKnowledgeAbout(ctx, tx, docID, derefSlice(body.About))
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge created", "slug", slug, "kind", kind, "actor", actorLabel, "linked", linked)
|
||||
// Content-Type before WriteHeader — setting it after is a no-op, the
|
||||
// status line is already on the wire.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"slug": slug, "id": docID.String(), "linked": linked,
|
||||
}); err != nil {
|
||||
slog.Error("httpapi: json encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveUpdateKnowledge edits a live note in place. The prior version is
|
||||
// captured by the trg_knowledge_revision trigger, not by this handler — see
|
||||
// the migration for why that lives in the database.
|
||||
//
|
||||
// Note the slug is intentionally NOT recomputed when the title changes:
|
||||
// slugs are the wiki's stable link target ([[slug]] references, relationship
|
||||
// rows, bookmarked window ids), and silently re-slugging on a typo fix would
|
||||
// break every inbound link.
|
||||
func (s *Server) serveUpdateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var body knowledgeWriteBody
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Title == nil && body.Content == nil && body.Tags == nil && body.About == nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "nothing to update",
|
||||
"supply at least one of title, content, tags, about")
|
||||
return
|
||||
}
|
||||
if body.Title != nil && strings.TrimSpace(*body.Title) == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "title cannot be empty", "")
|
||||
return
|
||||
}
|
||||
if body.Content != nil && strings.TrimSpace(*body.Content) == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "content cannot be empty", "")
|
||||
return
|
||||
}
|
||||
|
||||
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// COALESCE keeps unsupplied fields untouched; edited_by and updated_at
|
||||
// always move so the UI can show who last touched it. The trigger only
|
||||
// snapshots when title/content/tags actually differ, so a no-op save
|
||||
// doesn't manufacture a revision.
|
||||
var newTitle *string
|
||||
if body.Title != nil {
|
||||
t := strings.TrimSpace(*body.Title)
|
||||
newTitle = &t
|
||||
}
|
||||
var newContent *string
|
||||
if body.Content != nil {
|
||||
c := strings.TrimSpace(*body.Content)
|
||||
newContent = &c
|
||||
}
|
||||
var newTags *[]string
|
||||
if body.Tags != nil {
|
||||
t := normalizeTags(*body.Tags)
|
||||
newTags = &t
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities
|
||||
SET title = COALESCE($2, title),
|
||||
content = COALESCE($3, content),
|
||||
tags = COALESCE($4, tags),
|
||||
edited_by = $5,
|
||||
updated_at = now()
|
||||
WHERE entity_id = $1`,
|
||||
entityID, newTitle, newContent, newTags, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "update failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the entity's display name in step with the note title — the graph
|
||||
// and the fleet table read entities.name, and leaving it stale is exactly
|
||||
// the drift this app exists to fight.
|
||||
if newTitle != nil {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE entities SET name = $2, updated_at = now() WHERE id = $1`,
|
||||
entityID, *newTitle); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "rename entity failed", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// About is replace-semantics, not merge: the editor presents the full
|
||||
// link set, so an absent slug means the operator removed it. Existing
|
||||
// edges are closed (valid_to) rather than deleted, preserving history.
|
||||
var linked []string
|
||||
if body.About != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND valid_to IS NULL AND type IN ('documents', 'about')`,
|
||||
entityID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "unlink failed", err.Error())
|
||||
return
|
||||
}
|
||||
linked = s.linkKnowledgeAbout(ctx, tx, entityID, *body.About)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge updated", "entity_id", entityID, "actor", actorLabel)
|
||||
// `linked` lets the caller diff against what it submitted and warn about
|
||||
// any slug that didn't resolve — see linkKnowledgeAbout: a typo'd entity
|
||||
// slug otherwise fails with nothing but a server-side slog.Warn, so the
|
||||
// operator gets no feedback that one of their About links didn't take.
|
||||
writeJSON(w, map[string]any{"ok": true, "linked": linked})
|
||||
}
|
||||
|
||||
// serveDeleteKnowledge soft-deletes a note. The row, its revision trail and
|
||||
// its entity all survive; only the deleted_at stamp changes, and every read
|
||||
// path filters on it.
|
||||
func (s *Server) serveDeleteKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
// Snapshot the live version before tombstoning. The trigger fires on
|
||||
// title/content/tags changes only, and a delete changes none of them —
|
||||
// without this the most recent version would be the one version missing
|
||||
// from the history if the note is later restored.
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO knowledge_revisions
|
||||
(entity_id, title, content, source, tags, edited_by, version_at)
|
||||
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
|
||||
FROM knowledge_entities WHERE entity_id = $1`, entityID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "snapshot failed", err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET deleted_at = now(), edited_by = $2
|
||||
WHERE entity_id = $1`, entityID, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "delete failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge deleted", "entity_id", entityID, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// serveRestoreKnowledge undoes a soft delete. The counterpart to
|
||||
// serveDeleteKnowledge — without it, "recoverable by clearing the column"
|
||||
// (see the migration) would only be true via psql, which isn't a real
|
||||
// recovery path for an operator using the wiki.
|
||||
func (s *Server) serveRestoreKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
ct, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET deleted_at = NULL, edited_by = $2
|
||||
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID, actorLabel)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "restore failed", err.Error())
|
||||
return
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
writeProblem(w, req, http.StatusConflict, "note is not deleted", "")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge restored", "entity_id", entityID, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// serveKnowledgeRevisions returns the note's superseded versions, newest
|
||||
// first. Bodies are included: revisions are small (~1 KB) and few, and the
|
||||
// diff view needs both sides anyway — paginating would cost a round trip per
|
||||
// comparison to save nothing.
|
||||
func (s *Server) serveKnowledgeRevisions(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, title, content, COALESCE(edited_by,''), COALESCE(tags,'{}'),
|
||||
version_at::text, revised_at::text
|
||||
FROM knowledge_revisions
|
||||
WHERE entity_id = $1
|
||||
ORDER BY version_at DESC`, entityID)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type revision struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
Tags []string `json:"tags"`
|
||||
VersionAt string `json:"version_at"`
|
||||
RevisedAt string `json:"revised_at"`
|
||||
}
|
||||
items := []revision{}
|
||||
for rows.Next() {
|
||||
var r revision
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Content, &r.EditedBy, &r.Tags,
|
||||
&r.VersionAt, &r.RevisedAt); err != nil {
|
||||
slog.Error("httpapi: knowledge/revisions row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, r)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// linkKnowledgeAbout points a note at the entities it concerns, skipping
|
||||
// slugs that don't resolve and edges that already exist. Returns the slugs
|
||||
// actually linked so the caller can report what stuck — a typo'd slug is a
|
||||
// silent no-op otherwise.
|
||||
func (s *Server) linkKnowledgeAbout(ctx context.Context, tx pgx.Tx, docID uuid.UUID, slugs []string) []string {
|
||||
linked := []string{}
|
||||
for _, raw := range slugs {
|
||||
slug := strings.TrimSpace(raw)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
var targetID uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&targetID); err != nil {
|
||||
slog.Warn("knowledge: about slug not found, skipping", "slug", slug)
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'about', '{"by":"operator"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'about' AND valid_to IS NULL)`,
|
||||
docID, targetID); err != nil {
|
||||
slog.Warn("knowledge: link failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
linked = append(linked, slug)
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
// normalizeTags trims, lowercases and de-duplicates while preserving order.
|
||||
// Lowercasing is the fix for the casing drift already in the data — `oom`
|
||||
// and `OOM` were separate tags on separate notes, so neither tag page showed
|
||||
// the full set. Applied on every write so the split can't reopen.
|
||||
func normalizeTags(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, t := range in {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func derefSlice(p *[]string) []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// writeJSON is the success-path counterpart to writeProblem, so the handlers
|
||||
// in this file don't each repeat the header/encode dance.
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("httpapi: json encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
|
||||
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
|
||||
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
|
||||
// /api/v1/knowledge/list — full tree listing, ad-hoc aggregate
|
||||
// /api/v1/knowledge (POST) — markdown in, no gen type
|
||||
// /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
|
||||
// /api/v1/knowledge/trash — soft-deleted notes, ad-hoc
|
||||
// /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
|
||||
// /api/v1/knowledge/revisions/{id} — version history, no schema type
|
||||
// /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
|
||||
// /api/v1/knowledge/duplicates — trigram clustering, ad-hoc
|
||||
// /api/v1/knowledge/orphans — derived maintenance view
|
||||
// /api/v1/knowledge/merge — bulk fold-in, ad-hoc
|
||||
// /api/v1/activity/recent — recency-ordered, not paginated
|
||||
// /api/v1/activity/session/{id} — session-scoped aggregation
|
||||
// /api/v1/executions/{id}/logs — streamed command output, no schema type
|
||||
@@ -203,6 +213,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the operator-facing knowledge CRUD surface
|
||||
// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
|
||||
// knowledge_drift.go). Before these, knowledge could only be written by
|
||||
// the agent through the MCP upsert_knowledge tool — the web UI had no way
|
||||
// to create, correct or retire a note.
|
||||
//
|
||||
// Registered on the base router rather than through the OpenAPI codegen
|
||||
// for the same reason as the read routes above: they trade in raw
|
||||
// markdown and ad-hoc aggregates, not generated schema types.
|
||||
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)
|
||||
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||
// unlike ListExecutions which sorts by target for pagination) and the
|
||||
// per-session "what did this session do" digest.
|
||||
|
||||
Reference in New Issue
Block a user