174 lines
6.2 KiB
Go
174 lines
6.2 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
|
)
|
|
|
|
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
|
// content_hash handle: brain_write / brain_update return it, brain_get
|
|
// recomputes it from the file on disk, and brain_update stamps the prior
|
|
// note's hash into the new note's `supersedes` frontmatter.
|
|
func ContentHash(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// resolveWithin maps a brainDir-relative path to an absolute path and
|
|
// guarantees it does not escape brainDir. Returns the cleaned relPath
|
|
// (forward-slashed) and the absolute path.
|
|
func resolveWithin(brainDir, relPath string) (rel, abs string, err error) {
|
|
clean := filepath.Clean("/" + filepath.ToSlash(relPath))
|
|
rel = strings.TrimPrefix(clean, "/")
|
|
abs = filepath.Join(brainDir, filepath.FromSlash(rel))
|
|
check, err := filepath.Rel(brainDir, abs)
|
|
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
|
|
return "", "", fmt.Errorf("path %q escapes brain dir", relPath)
|
|
}
|
|
return rel, abs, nil
|
|
}
|
|
|
|
// UpdateNoteOptions identifies the note to supersede and supplies its new
|
|
// body. Path takes precedence; otherwise the target is resolved from
|
|
// Wing/Hall/Slug via brain.NotePath.
|
|
type UpdateNoteOptions struct {
|
|
Path string // brainDir-relative path; takes precedence over wing/hall/slug
|
|
Wing string
|
|
Hall string
|
|
Slug string
|
|
Content string // new full body (whole-note replace)
|
|
Reason string // optional; stamped as supersede_reason
|
|
}
|
|
|
|
// UpdateNote supersedes an existing note in place. It replaces the body
|
|
// with opts.Content, preserves the existing frontmatter (created_at,
|
|
// wing, hall, and any custom fields), and stamps updated_at, supersedes
|
|
// (the prior content hash), and supersede_reason (when given).
|
|
//
|
|
// It never creates: if the target does not exist, it returns an error so
|
|
// the caller can fall back to brain_write. Returns the note's relPath,
|
|
// the new content hash, and the prior content hash.
|
|
//
|
|
// Embeddings are NOT refreshed here. The rewritten file's mtime advances,
|
|
// which the mtime-driven vectorstore.Sync ticker uses to re-embed it on
|
|
// its next pass — the same out-of-band mechanism brain_write relies on.
|
|
func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, priorHash string, err error) {
|
|
if opts.Content == "" {
|
|
return "", "", "", fmt.Errorf("content is required")
|
|
}
|
|
|
|
// Same reason as WriteNote: the strip belongs in the primitive so every
|
|
// door inherits it.
|
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
opts.Content = clean
|
|
|
|
var rel string
|
|
if opts.Path != "" {
|
|
rel = opts.Path
|
|
} else {
|
|
full, perr := brain.NotePath(brainDir, opts.Wing, opts.Hall, opts.Slug)
|
|
if perr != nil {
|
|
return "", "", "", perr
|
|
}
|
|
rel, _ = filepath.Rel(brainDir, full)
|
|
rel = filepath.ToSlash(rel)
|
|
}
|
|
|
|
rel, abs, err := resolveWithin(brainDir, rel)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
prior, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
|
|
}
|
|
return "", "", "", fmt.Errorf("read target: %w", err)
|
|
}
|
|
priorHash = ContentHash(prior)
|
|
|
|
// A caller may lead opts.Content with its own frontmatter fence to set or
|
|
// change fields on supersede (e.g. the claim envelope). Split it off
|
|
// before it reaches the body — left un-split, it would render as a
|
|
// second, inert `---`-fenced block of literal text rather than taking
|
|
// effect, and any claim-envelope fields inside it would reach disk
|
|
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
|
// writeHallNote must guard a change to the same fields).
|
|
contentFM, contentBody := parseFrontmatter(opts.Content)
|
|
|
|
fm, _ := parseFrontmatter(string(prior))
|
|
for _, l := range contentFM.lines {
|
|
if l.key != "" {
|
|
fm.set(l.key, l.value)
|
|
}
|
|
}
|
|
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
|
fm.set("supersedes", priorHash)
|
|
if opts.Reason != "" {
|
|
fm.set("supersede_reason", opts.Reason)
|
|
}
|
|
|
|
// Validate the envelope AFTER the merge, against the full field set that
|
|
// is about to reach disk — not just the caller's fragment. Validating the
|
|
// fragment alone would miss a caller that supersedes claim_provenance to
|
|
// empty while half_life/applies survive untouched from the prior note:
|
|
// each side looks individually fine, but the merged result is an
|
|
// unprovenanced envelope, which is the exact laundering this check
|
|
// exists to catch. (Caught in review before merge, not filed after —
|
|
// worth being honest about in the history: this second pass was the
|
|
// actual fix; the first one validated the wrong document.)
|
|
envelope := make(map[string]string, len(fm.lines))
|
|
for _, l := range fm.lines {
|
|
if l.key != "" {
|
|
envelope[l.key] = l.value
|
|
}
|
|
}
|
|
if err := claim.ValidateEnvelope(envelope); err != nil {
|
|
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
|
}
|
|
|
|
out := []byte(fm.render() + contentBody)
|
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
|
return "", "", "", fmt.Errorf("write: %w", err)
|
|
}
|
|
return rel, ContentHash(out), priorHash, nil
|
|
}
|
|
|
|
// ReadNote reads the note at the brainDir-relative relPath and returns
|
|
// its parsed frontmatter, body, and content hash. It is the read-after-
|
|
// write primitive behind brain_get: the hash it returns equals the hash
|
|
// brain_write / brain_update returned for the same bytes.
|
|
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
|
|
_, abs, err := resolveWithin(brainDir, relPath)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
|
|
}
|
|
return nil, "", "", fmt.Errorf("read note: %w", err)
|
|
}
|
|
parsed, body := parseFrontmatter(string(raw))
|
|
fm = make(map[string]string, len(parsed.lines))
|
|
for _, l := range parsed.lines {
|
|
if l.key != "" {
|
|
fm[l.key] = l.value
|
|
}
|
|
}
|
|
return fm, body, ContentHash(raw), nil
|
|
}
|