The Knowledge page was read-only from the HTTP API — the only writer was the agent's MCP upsert_knowledge tool. Adds create/update/soft-delete/ restore/trash endpoints, a DB-trigger-backed revision history (catches both the web UI and the MCP tool), and maintenance endpoints: duplicate detection (pg_trgm + complete-linkage clustering), tag rename/normalize, orphan detection, and merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
660 lines
24 KiB
Go
660 lines
24 KiB
Go
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)
|
|
}
|
|
}
|