hyperguild#81. brain_pending lists brain/raw/ notes awaiting review; brain_promote moves one into the wiki. There was no way to say "reviewed, not worth promoting" — the only options were promote it or let it sit forever. Live consequence, confirmed this session: the pending queue has grown to 11 items, the oldest 152 days old, several of which are generic web scrapes nobody is ever going to promote but nobody could clear either except an un-audited rm bypassing the tool surface entirely. RejectNote (internal/api/promote.go) mirrors PromoteNote's exact safety shape: same basename-only filename validation, same write-then-delete ordering (the source survives any write failure), same collision check (never silently overwrite an existing rejected note under the same name). Stamps rejected_at (+ reject_reason if given) into frontmatter, moves brain/raw/<filename> -> brain/raw/rejected/<filename>. No index rebuild or auto-tunnel -- a rejected note never entered the wiki, so there's nothing to index. raw/rejected/ is a subdirectory ListPending's non-recursive os.ReadDir never walks into, so a rejected note is never re-surfaced as pending -- the human decision, once made, stays made. brain_reject wired into the MCP tool list and dispatch, mirroring brain_promote's schema shape (filename required, reason optional). Human-invoked only, same as promote -- never called automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
204 lines
7.9 KiB
Go
204 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func writeRaw(t *testing.T, brainDir, name, content string) {
|
|
t.Helper()
|
|
dir := filepath.Join(brainDir, "raw")
|
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
|
}
|
|
|
|
func TestListPendingEmptyWhenAbsent(t *testing.T) {
|
|
got, err := ListPending(t.TempDir())
|
|
require.NoError(t, err, "absent raw/ is not an error")
|
|
assert.Empty(t, got)
|
|
}
|
|
|
|
func TestListPendingReturnsOldestFirstWithExcerpt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-02-newer.md", "---\ncreated_at: 2026-06-02T00:00:00Z\n---\nNewer body here.\n")
|
|
writeRaw(t, dir, "2026-06-01-older.md", "---\ncreated_at: 2026-06-01T00:00:00Z\n---\nOlder body content.\n")
|
|
// non-md ignored
|
|
writeRaw(t, dir, "notes.txt", "ignore me")
|
|
|
|
got, err := ListPending(dir)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 2)
|
|
assert.Equal(t, "2026-06-01-older.md", got[0].Filename, "oldest first")
|
|
assert.Equal(t, "2026-06-02-newer.md", got[1].Filename)
|
|
assert.Contains(t, got[0].Excerpt, "Older body content")
|
|
assert.NotContains(t, got[0].Excerpt, "---", "excerpt is body, not frontmatter")
|
|
assert.Positive(t, got[0].SizeBytes)
|
|
}
|
|
|
|
func TestPromoteHappyPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-01-lejpa-decision.md",
|
|
"---\ncreated_at: 2026-06-01T09:00:00Z\ncustom_field: keep-me\n---\n# LeJEPA\n\nbody.\n")
|
|
|
|
rel, err := PromoteNote(dir, PromoteOptions{
|
|
Filename: "2026-06-01-lejpa-decision.md", Wing: "jepa-fx", Hall: "decisions",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "wiki/jepa-fx/decisions/lejpa-decision.md", rel, "slug defaults to filename minus date prefix")
|
|
|
|
// Source deleted.
|
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-lejpa-decision.md"))
|
|
assert.True(t, os.IsNotExist(statErr), "source removed after promote")
|
|
|
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
s := string(got)
|
|
assert.Contains(t, s, "wing: jepa-fx")
|
|
assert.Contains(t, s, "hall: decisions")
|
|
assert.Contains(t, s, "created_at: 2026-06-01T09:00:00Z", "original created_at preserved")
|
|
assert.Contains(t, s, "promoted_at:")
|
|
assert.Contains(t, s, "custom_field: keep-me", "custom frontmatter preserved")
|
|
assert.Contains(t, s, "# LeJEPA")
|
|
}
|
|
|
|
func TestPromoteExplicitSlug(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts", Slug: "custom-slug"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "wiki/a/facts/custom-slug.md", rel)
|
|
}
|
|
|
|
func TestPromoteInvalidHallErrorsBeforeTouchingFS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "garbage"})
|
|
require.Error(t, err)
|
|
// Source untouched.
|
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
|
assert.NoError(t, statErr, "invalid hall must not delete or move the source")
|
|
}
|
|
|
|
func TestPromoteMissingSourceErrors(t *testing.T) {
|
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "ghost.md", Wing: "a", Hall: "facts"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestPromoteSlugCollisionNoOverwrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Pre-existing target.
|
|
dest := filepath.Join(dir, "wiki", "a", "facts", "x.md")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
|
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
|
writeRaw(t, dir, "2026-06-01-x.md", "NEW\n")
|
|
|
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
|
require.Error(t, err, "collision must error, not overwrite")
|
|
|
|
got, _ := os.ReadFile(dest)
|
|
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
|
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
|
}
|
|
|
|
func TestPromoteRejectsTraversalFilename(t *testing.T) {
|
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "../escape.md", Wing: "a", Hall: "facts"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestPromoteRebuildsWingIndex(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-01-x.md", "---\ntitle: X Note\n---\nbody\n")
|
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
|
require.NoError(t, err)
|
|
idx, err := os.ReadFile(filepath.Join(dir, "wiki", "a", "_index.md"))
|
|
require.NoError(t, err, "wing _index regenerated")
|
|
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
|
}
|
|
|
|
// --- RejectNote (hyperguild#81) ---------------------------------------------
|
|
|
|
func TestRejectHappyPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-04-23-fed-sentiment-scrape.md",
|
|
"---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Sentiment scrape\n\nbody.\n")
|
|
|
|
rel, err := RejectNote(dir, RejectOptions{
|
|
Filename: "2026-04-23-fed-sentiment-scrape.md", Reason: "generic scrape, not homelab-relevant",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "raw/rejected/2026-04-23-fed-sentiment-scrape.md", rel)
|
|
|
|
// Source removed from the pending queue.
|
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-04-23-fed-sentiment-scrape.md"))
|
|
assert.True(t, os.IsNotExist(statErr), "source removed from raw/ after reject")
|
|
|
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
|
require.NoError(t, err)
|
|
s := string(got)
|
|
assert.Contains(t, s, "created_at: 2026-04-23T13:20:07Z", "original created_at preserved")
|
|
assert.Contains(t, s, "rejected_at:")
|
|
assert.Contains(t, s, "reject_reason: generic scrape, not homelab-relevant")
|
|
assert.Contains(t, s, "# Sentiment scrape", "body preserved for later reference")
|
|
|
|
// Rejected notes never entered the wiki, and are no longer pending.
|
|
pending, err := ListPending(dir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, pending)
|
|
}
|
|
|
|
func TestRejectWithoutReason(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
|
rel, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
|
|
require.NoError(t, err)
|
|
got, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
|
assert.NotContains(t, string(got), "reject_reason:", "no reason given, none stamped")
|
|
}
|
|
|
|
func TestRejectMissingSourceErrors(t *testing.T) {
|
|
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "ghost.md"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestRejectRejectsTraversalFilename(t *testing.T) {
|
|
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "../escape.md"})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestRejectCollisionNoOverwrite(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dest := filepath.Join(dir, "raw", "rejected", "x.md")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
|
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
|
writeRaw(t, dir, "x.md", "NEW\n")
|
|
|
|
_, err := RejectNote(dir, RejectOptions{Filename: "x.md"})
|
|
require.Error(t, err, "collision must error, not overwrite")
|
|
|
|
got, _ := os.ReadFile(dest)
|
|
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "x.md"))
|
|
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
|
}
|
|
|
|
func TestRejectedNotesAreNotListedAsPendingAgain(t *testing.T) {
|
|
// ListPending already skips tunnel-candidates-*.md; confirm raw/rejected/
|
|
// (a subdirectory) doesn't leak into the top-level pending listing either
|
|
// — os.ReadDir on raw/ doesn't recurse, but pin it so a future change to
|
|
// ListPending can't silently start surfacing rejected notes as pending
|
|
// again.
|
|
dir := t.TempDir()
|
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
|
_, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
|
|
require.NoError(t, err)
|
|
|
|
pending, err := ListPending(dir)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, pending, "a rejected note must not still appear as pending")
|
|
}
|