Round 2 of the PR #147 review forged origin: static for yaml.v3 with an alias key plus a "..." document-end line: per-line judgement cannot see context-dependent YAML. It also found REST ingest writing pages with no provenance (stampPages lived only in the MCP server). - New package internal/provenance. Apply keeps only top-level groups `key: value` with a bare-identifier key whose group decodes on its own to exactly that key, drops reserved and duplicate keys and everything else (aliases, anchors as keys, document markers, complex/merge keys, flow maps, tags, quoted keys), appends the stamp, then decodes the whole result with yaml.v3 and refuses the write unless it reads back exactly the stamp. api.StampProvenance wraps it and now returns an error, which every caller propagates. - The ingest pipeline stamps every page it writes or merges, from provenance.FromContext(ctx); MCP attaches the caller's stamp, REST and the watcher stamp unknown. RunRaw takes a ctx. - brain_edit snapshots the note and rolls the edit back if the stamp cannot be verified. Tests: R1/R2 payloads join the write/update variant table, plus brain_edit with the alias payload; REST /ingest-raw (title newline checked with the line-oriented reader) and REST /ingest (stub LLM) stamp unknown. Two existing byte-exact tests updated for the stamp line. Refs #145 #144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
7.7 KiB
Go
227 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
)
|
|
|
|
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
|
|
type PendingNote struct {
|
|
Filename string `json:"filename"`
|
|
CreatedAt string `json:"created_at"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
Excerpt string `json:"excerpt"`
|
|
}
|
|
|
|
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
|
// deriving the default promoted slug.
|
|
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
|
|
|
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
|
|
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
|
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
|
func ListPending(brainDir string) ([]PendingNote, error) {
|
|
dir := filepath.Join(brainDir, "raw")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []PendingNote{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
|
}
|
|
|
|
out := make([]PendingNote, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
|
continue
|
|
}
|
|
info, statErr := e.Info()
|
|
if statErr != nil {
|
|
continue
|
|
}
|
|
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
fm, body := parseFrontmatter(string(raw))
|
|
created := fm.get("created_at")
|
|
if created == "" {
|
|
created = info.ModTime().UTC().Format(time.RFC3339)
|
|
}
|
|
out = append(out, PendingNote{
|
|
Filename: e.Name(),
|
|
CreatedAt: created,
|
|
SizeBytes: info.Size(),
|
|
Excerpt: excerpt(body, 200),
|
|
})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
|
return out, nil
|
|
}
|
|
|
|
// PromoteOptions identifies a raw note to promote and its wiki destination.
|
|
type PromoteOptions struct {
|
|
Filename string // basename in brain/raw/
|
|
Wing string
|
|
Hall string
|
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
|
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
|
// note's own frontmatter claims are not provenance (hyperguild#145).
|
|
Origin string
|
|
WrittenBy string
|
|
}
|
|
|
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
|
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
|
|
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
|
|
// the source, then rebuilds the wing index and runs auto-tunnel detection.
|
|
//
|
|
// It is atomic from the caller's view: validation (hall, wing, slug,
|
|
// collision) happens before any filesystem change, and the source is deleted
|
|
// only after the destination write succeeds (write-then-delete, never move).
|
|
// Returns the promoted note's path relative to brainDir.
|
|
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|
// Validate filename (basename only — no traversal) before touching fs.
|
|
base := filepath.Base(opts.Filename)
|
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
|
}
|
|
|
|
slug := opts.Slug
|
|
if slug == "" {
|
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
|
}
|
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
src := filepath.Join(brainDir, "raw", base)
|
|
raw, err := os.ReadFile(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
}
|
|
return "", fmt.Errorf("read source: %w", err)
|
|
}
|
|
|
|
// Collision: never silently overwrite an existing promoted note.
|
|
if _, statErr := os.Stat(dest); statErr == nil {
|
|
rel, _ := filepath.Rel(brainDir, dest)
|
|
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
|
|
}
|
|
|
|
fm, body := parseFrontmatter(string(raw))
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
|
fm.set("hall", opts.Hall)
|
|
if fm.get("created_at") == "" {
|
|
fm.set("created_at", now)
|
|
}
|
|
fm.set("promoted_at", now)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return "", fmt.Errorf("create wing dir: %w", err)
|
|
}
|
|
// Write-then-delete: the source survives any write failure.
|
|
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
|
|
return "", fmt.Errorf("write promoted note: %w", err)
|
|
}
|
|
if err := os.Remove(src); err != nil {
|
|
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
|
}
|
|
|
|
rel, _ := filepath.Rel(brainDir, dest)
|
|
relSlash := filepath.ToSlash(rel)
|
|
|
|
// Best-effort wiki upkeep — the note is already promoted.
|
|
_ = brain.BuildWingIndex(brainDir, opts.Wing)
|
|
_ = brain.AutoTunnel(brainDir, relSlash, body)
|
|
|
|
return relSlash, nil
|
|
}
|
|
|
|
// RejectOptions identifies a raw note to reject and why.
|
|
type RejectOptions struct {
|
|
Filename string // basename in brain/raw/
|
|
Reason string // optional
|
|
}
|
|
|
|
// RejectNote records a human decision not to promote a brain/raw/ note,
|
|
// without silently losing it: it stamps rejected_at (+ reject_reason when
|
|
// given) into frontmatter and moves the file to brain/raw/rejected/, using
|
|
// the same write-then-delete safety PromoteNote uses — the source survives
|
|
// any write failure — and the same basename-only filename validation.
|
|
//
|
|
// A rejected note never entered the wiki, so no index rebuild or
|
|
// auto-tunnel run: unlike PromoteNote, this is the end of the note's
|
|
// journey, not the start of a new one. It stays out of ListPending's view
|
|
// (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/
|
|
// does not recurse), so a rejected note is never re-surfaced as pending —
|
|
// the human decision, once made, stays made, the same principle behind
|
|
// this estate's "issue status is surfaced, not auto-closed, but once a
|
|
// human decides, the decision is recorded" rule applied one level earlier.
|
|
func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|
base := filepath.Base(opts.Filename)
|
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
|
}
|
|
|
|
src := filepath.Join(brainDir, "raw", base)
|
|
raw, err := os.ReadFile(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
}
|
|
return "", fmt.Errorf("read source: %w", err)
|
|
}
|
|
|
|
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
|
if _, statErr := os.Stat(dest); statErr == nil {
|
|
rel, _ := filepath.Rel(brainDir, dest)
|
|
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
|
}
|
|
|
|
fm, body := parseFrontmatter(string(raw))
|
|
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
|
if opts.Reason != "" {
|
|
fm.set("reject_reason", opts.Reason)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return "", fmt.Errorf("create rejected dir: %w", err)
|
|
}
|
|
// Write-then-delete: the source survives any write failure.
|
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
|
return "", fmt.Errorf("write rejected note: %w", err)
|
|
}
|
|
if err := os.Remove(src); err != nil {
|
|
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
|
}
|
|
|
|
rel, _ := filepath.Rel(brainDir, dest)
|
|
return filepath.ToSlash(rel), nil
|
|
}
|
|
|
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
|
func excerpt(s string, n int) string {
|
|
s = strings.TrimSpace(s)
|
|
r := []rune(s)
|
|
if len(r) > n {
|
|
r = r[:n]
|
|
}
|
|
return strings.TrimSpace(string(r))
|
|
}
|