fix(api,watcher): name the queue, list parked non-markdown, reserved wings, top-level-only frontmatter key; document the inbox (review of #211)
- brain_promote/brain_reject take an optional queue (inbox | needs-review); a name present in both is refused instead of silently acting on the top-level note; brain_pending labels every entry with its queue - parked pdf/txt files are listed (they were invisible); only markdown can be promoted, the others are moved or rejected - promote refuses sources/concepts/entities/wiki as a wing - frontmatterWing reads only a top-level key inside a properly fenced block - docs/raw-inbox.md: the contract, the three env vars, the known limits Each behaviour has a test that fails without it (mutation-checked). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# The raw/ inbox
|
||||||
|
|
||||||
|
`raw/` is an inbox: only unprocessed material sits there. Syncthing shares it
|
||||||
|
with the machines you drop files from. (hyperguild#205, #208, #209)
|
||||||
|
|
||||||
|
## What happens to a file you drop
|
||||||
|
|
||||||
|
| You drop | Result |
|
||||||
|
|---|---|
|
||||||
|
| `raw/clip.md` (no folder, no `wing:`) | extracted and filed as a source in `wiki/web-clip/sources/`; its concepts and entities update the shared `wiki/concepts/` and `wiki/entities/` pages; the original moves to `raw/processed/<date>/`, so `raw/` is empty again |
|
||||||
|
| `raw/<wing>/clip.md` or `wing: <wing>` in the frontmatter | the same, with the source in `wiki/<wing>/sources/`. The wing must already exist; a typo is parked, never created. This is your deliberate choice and is not second-guessed |
|
||||||
|
| a note whose text matches the client-content pattern, with no folder | parked in `raw/needs-review/`, not extracted |
|
||||||
|
| a folder or `wing:` naming an unknown, reserved or non-ASCII wing, or two disagreeing signals | parked in `raw/needs-review/` |
|
||||||
|
| a note over the size cap, or one that cannot be archived | stays in `raw/`, reported once, not retried until it changes |
|
||||||
|
|
||||||
|
Concepts and entities are shared across wings because their slug is the graph's
|
||||||
|
identity. A source whose slug is taken gets a wing-qualified one (`notes-homelab`).
|
||||||
|
|
||||||
|
## Parked notes
|
||||||
|
|
||||||
|
`brain_pending` lists them with `queue: needs-review`, a proposed wing and the
|
||||||
|
reason. To act on one: `brain_promote` / `brain_reject` (pass `queue` when the
|
||||||
|
same name also sits in the inbox), or move the file into `raw/<wing>/` to have it
|
||||||
|
extracted. Parked pdf/txt files are listed but can only be moved or rejected.
|
||||||
|
|
||||||
|
## Switching it on
|
||||||
|
|
||||||
|
Routing is off by default (the retired layout is used). Set on the ingestion
|
||||||
|
deployment:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `INGEST_WING_ROUTING=1` | enable the rules above |
|
||||||
|
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
|
||||||
|
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. It catches what it names and nothing else |
|
||||||
|
|
||||||
|
A bad default or pattern stops the server at startup. A wing created while the
|
||||||
|
server runs is usable without a restart.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- A deliberate folder into a sensitive wing places the note there, and the shared
|
||||||
|
concept/entity pages then cite that source by title. Client material does not
|
||||||
|
belong in the brain at all; the pattern only guards the unsignalled lane.
|
||||||
|
- A merge into a shared page refreshes `last_updated` and `origin` to the latest
|
||||||
|
ingest; the first wing's attribution is not kept.
|
||||||
|
- A marker file (`<note>.processed`) is only written when a processed original
|
||||||
|
cannot be removed. One at least as new as its note holds the note (and says
|
||||||
|
so once); an older or future-dated one is ignored and removed.
|
||||||
@@ -617,6 +617,7 @@ type promoteRequest struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
@@ -630,7 +631,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Field by field: this door carries no principal, so the promoted note is
|
// Field by field: this door carries no principal, so the promoted note is
|
||||||
// stamped origin: unknown and the body cannot set provenance (#145).
|
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||||
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||||
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) {
|
|||||||
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
||||||
|
|
||||||
assert.Equal(t, "fresh.md", got[1].Filename)
|
assert.Equal(t, "fresh.md", got[1].Filename)
|
||||||
assert.Empty(t, got[1].Queue, "a top-level note has no queue label")
|
assert.Equal(t, QueueInbox, got[1].Queue)
|
||||||
assert.Empty(t, got[1].ProposedWing)
|
assert.Empty(t, got[1].ProposedWing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type PendingNote struct {
|
|||||||
// (hyperguild#205): it was not placed automatically, and the proposal says
|
// (hyperguild#205): it was not placed automatically, and the proposal says
|
||||||
// what the resolver would have done and why it did not. Approve by moving
|
// what the resolver would have done and why it did not. Approve by moving
|
||||||
// the note into raw/<wing>/ and the watcher ingests it there.
|
// the note into raw/<wing>/ and the watcher ingests it there.
|
||||||
Queue string `json:"queue,omitempty"`
|
Queue string `json:"queue"`
|
||||||
ProposedWing string `json:"proposed_wing,omitempty"`
|
ProposedWing string `json:"proposed_wing,omitempty"`
|
||||||
ProposalReason string `json:"proposal_reason,omitempty"`
|
ProposalReason string `json:"proposal_reason,omitempty"`
|
||||||
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
||||||
@@ -49,7 +49,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
|
|
||||||
out, err := listNotes(root, "")
|
out, err := listNotes(root, QueueInbox)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
// or note there is refused exactly like at the top level (#153).
|
// or note there is refused exactly like at the top level (#153).
|
||||||
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
||||||
defer func() { _ = sub.Close() }()
|
defer func() { _ = sub.Close() }()
|
||||||
parked, perr := listNotes(sub, "needs-review")
|
parked, perr := listNotes(sub, QueueNeedsReview)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, perr
|
return nil, perr
|
||||||
}
|
}
|
||||||
@@ -76,9 +76,25 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
}
|
}
|
||||||
out := make([]PendingNote, 0, len(entries))
|
out := make([]PendingNote, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
isMD := strings.HasSuffix(e.Name(), ".md")
|
||||||
|
// A parked pdf or txt is listed too (the watcher parks any supported
|
||||||
|
// file); at the top level only markdown ever was.
|
||||||
|
parkedOther := queue == QueueNeedsReview && (strings.HasSuffix(e.Name(), ".txt") || strings.HasSuffix(e.Name(), ".pdf"))
|
||||||
|
if !e.Type().IsRegular() || (!isMD && !parkedOther) || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
continue // directories, and symlinks whose target may be anywhere (#153)
|
continue // directories, and symlinks whose target may be anywhere (#153)
|
||||||
}
|
}
|
||||||
|
if !isMD {
|
||||||
|
info, ierr := root.Lstat(e.Name())
|
||||||
|
if ierr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339), SizeBytes: info.Size(),
|
||||||
|
Excerpt: "(not markdown; the wing proposal is in log.md. Move the file into raw/<wing>/ to ingest it.)",
|
||||||
|
Queue: queue,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
raw, info, readErr := readRawNote(root, e.Name())
|
raw, info, readErr := readRawNote(root, e.Name())
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
continue
|
continue
|
||||||
@@ -95,7 +111,7 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
Excerpt: excerpt(body, 200),
|
Excerpt: excerpt(body, 200),
|
||||||
Queue: queue,
|
Queue: queue,
|
||||||
}
|
}
|
||||||
if queue != "" {
|
if queue == QueueNeedsReview {
|
||||||
n.ProposedWing = fm.get("proposed_wing")
|
n.ProposedWing = fm.get("proposed_wing")
|
||||||
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
||||||
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
||||||
@@ -111,6 +127,9 @@ type PromoteOptions struct {
|
|||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
// Queue names which listing the note is in: QueueInbox or QueueNeedsReview.
|
||||||
|
// Empty is accepted only when the name exists in exactly one of them.
|
||||||
|
Queue string
|
||||||
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||||
Origin string
|
Origin string
|
||||||
@@ -133,17 +152,26 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(base, ".md") {
|
||||||
|
return "", fmt.Errorf("only a markdown note can be promoted; move %q into raw/<wing>/ to ingest it", base)
|
||||||
|
}
|
||||||
slug := opts.Slug
|
slug := opts.Slug
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
}
|
}
|
||||||
|
// The retired layout's directory names are not wings: a note promoted into
|
||||||
|
// "sources" would land in wiki/sources/<hall>/, inside the retired dir.
|
||||||
|
switch brain.Sanitise(opts.Wing) {
|
||||||
|
case "sources", "concepts", "entities", "wiki":
|
||||||
|
return "", fmt.Errorf("%q is not a wing", opts.Wing)
|
||||||
|
}
|
||||||
// NotePath validates hall + wing + slug; do this before reading anything.
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, srcRel, err := readRawSource(brainDir, base)
|
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -198,6 +226,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
type RejectOptions struct {
|
type RejectOptions struct {
|
||||||
Filename string // basename in brain/raw/
|
Filename string // basename in brain/raw/
|
||||||
Reason string // optional
|
Reason string // optional
|
||||||
|
Queue string // as PromoteOptions.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectNote records a human decision not to promote a brain/raw/ note,
|
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||||
@@ -220,7 +249,7 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, srcRel, err := readRawSource(brainDir, base)
|
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -263,7 +292,10 @@ func excerpt(s string, n int) string {
|
|||||||
|
|
||||||
// readRawSource reads a pending note for promote/reject through the raw root,
|
// readRawSource reads a pending note for promote/reject through the raw root,
|
||||||
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
||||||
func readRawSource(brainDir, base string) ([]byte, string, error) {
|
func readRawSource(brainDir, base, queue string) ([]byte, string, error) {
|
||||||
|
if queue != "" && queue != QueueInbox && queue != QueueNeedsReview {
|
||||||
|
return nil, "", fmt.Errorf("unknown queue %q (want %q or %q)", queue, QueueInbox, QueueNeedsReview)
|
||||||
|
}
|
||||||
root, _, err := OpenRawRoot(brainDir)
|
root, _, err := OpenRawRoot(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -272,34 +304,49 @@ func readRawSource(brainDir, base string) ([]byte, string, error) {
|
|||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
return nil, "", fmt.Errorf("read source: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
raw, _, err := readRawNote(root, base)
|
|
||||||
if err == nil {
|
var topRaw, parkedRaw []byte
|
||||||
return raw, "raw/" + base, nil
|
var topErr, parkedErr error
|
||||||
|
if queue != QueueNeedsReview {
|
||||||
|
topRaw, _, topErr = readRawNote(root, base)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
if queue != QueueInbox {
|
||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
// The sub-root refuses a symlinked directory like OpenRawRoot does.
|
||||||
}
|
if sub, serr := root.OpenRoot(parkedDir); serr != nil {
|
||||||
// brain_pending also lists notes parked in raw/needs-review/ under their
|
parkedErr = serr
|
||||||
// bare filename (hyperguild#208); the top level wins on a name clash. The
|
} else {
|
||||||
// sub-root refuses a symlinked directory like OpenRawRoot does.
|
parkedRaw, _, parkedErr = readRawNote(sub, base)
|
||||||
sub, serr := root.OpenRoot(parkedDir)
|
_ = sub.Close()
|
||||||
if serr != nil {
|
|
||||||
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
||||||
}
|
|
||||||
defer func() { _ = sub.Close() }()
|
|
||||||
raw, _, err = readRawNote(sub, base)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
||||||
}
|
}
|
||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
}
|
||||||
return raw, "raw/" + parkedDir + "/" + base, nil
|
haveTop := queue != QueueNeedsReview && topErr == nil
|
||||||
|
haveParked := queue != QueueInbox && parkedErr == nil
|
||||||
|
switch {
|
||||||
|
case haveTop && haveParked:
|
||||||
|
return nil, "", fmt.Errorf("%q is in both raw/ and raw/%s/: name the queue (%q or %q)", base, parkedDir, QueueInbox, QueueNeedsReview)
|
||||||
|
case haveTop:
|
||||||
|
return topRaw, "raw/" + base, nil
|
||||||
|
case haveParked:
|
||||||
|
return parkedRaw, "raw/" + parkedDir + "/" + base, nil
|
||||||
|
}
|
||||||
|
for _, e := range []error{topErr, parkedErr} {
|
||||||
|
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return nil, "", fmt.Errorf("read source: %w", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parkedDir is where the watcher parks notes it could not place.
|
// parkedDir is where the watcher parks notes it could not place.
|
||||||
const parkedDir = "needs-review"
|
const parkedDir = "needs-review"
|
||||||
|
|
||||||
|
// The two review queues brain_pending lists. promote and reject take one of
|
||||||
|
// them (or neither, when the name is unambiguous).
|
||||||
|
const (
|
||||||
|
QueueInbox = "inbox" // raw/<name>
|
||||||
|
QueueNeedsReview = "needs-review" // raw/needs-review/<name>
|
||||||
|
)
|
||||||
|
|
||||||
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
|
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
|
||||||
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
||||||
// returned as is.
|
// returned as is.
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// brain_pending lists a top-level note and a parked note of the same name as two
|
||||||
|
// entries with the same filename. Acting on "a.md" must not silently pick one.
|
||||||
|
func TestPromoteNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "both")
|
||||||
|
assert.Contains(t, err.Error(), "queue")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/a.md"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was promoted")
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
assert.Contains(t, string(got), "Body of the parked note.")
|
||||||
|
_, statErr = os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the top-level note of the same name is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_QueueInboxSelectsTheTopLevelNote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueInbox})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
assert.Contains(t, string(got), "Top-level body.")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the parked note is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_ANamedQueueThatDoesNotHoldTheNoteIsAnError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, err = PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: "elsewhere"})
|
||||||
|
assert.Error(t, err, "an unknown queue name is refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
_, err := RejectNote(dir, RejectOptions{Filename: "a.md"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = RejectNote(dir, RejectOptions{Filename: "a.md", Queue: QueueNeedsReview})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the top-level note is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_NeverIntoARetiredLayoutName(t *testing.T) {
|
||||||
|
for _, w := range []string{"sources", "concepts", "entities", "wiki"} {
|
||||||
|
t.Run(w, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki", w))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPending_LabelsEveryEntryWithItsQueue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nx\n")
|
||||||
|
writeParked(t, dir, "p.md", parkedNote)
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
queues := map[string]string{}
|
||||||
|
for _, n := range got {
|
||||||
|
queues[n.Filename] = n.Queue
|
||||||
|
}
|
||||||
|
assert.Equal(t, QueueInbox, queues["fresh.md"])
|
||||||
|
assert.Equal(t, QueueNeedsReview, queues["p.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parked notes that are not markdown (a pdf, a txt) were invisible: the only
|
||||||
|
// trace was a log.md line.
|
||||||
|
func TestListPending_ShowsParkedNonMarkdownFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF-1.4 binary"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "plain.txt"), []byte("some text"), 0o644))
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
names := map[string]PendingNote{}
|
||||||
|
for _, n := range got {
|
||||||
|
names[n.Filename] = n
|
||||||
|
}
|
||||||
|
require.Contains(t, names, "paper.pdf")
|
||||||
|
require.Contains(t, names, "plain.txt")
|
||||||
|
assert.Equal(t, QueueNeedsReview, names["paper.pdf"].Queue)
|
||||||
|
assert.Contains(t, names["paper.pdf"].Excerpt, "proposal", "points the reader at where the proposal is")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_OnlyMarkdownCanBePromoted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF binary"), 0o644))
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "paper.pdf", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "markdown")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
||||||
|
assert.NoError(t, statErr, "refused, untouched")
|
||||||
|
}
|
||||||
@@ -48,19 +48,6 @@ func TestRejectNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
|
|||||||
assert.Empty(t, pending, "a rejected note is not re-listed")
|
assert.Empty(t, pending, "a rejected note is not re-listed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromoteNote_ATopLevelNoteWinsOverAParkedOneOfTheSameName(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "same.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
|
||||||
writeParked(t, dir, "same.md", parkedNote)
|
|
||||||
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "same.md", Wing: "homelab", Hall: "facts"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/same.md"))
|
|
||||||
assert.Contains(t, string(got), "Top-level body.")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "same.md"))
|
|
||||||
assert.NoError(t, statErr, "the parked note of the same name is left alone")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
|
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
outside := t.TempDir()
|
outside := t.TempDir()
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"wing": str("target wing, e.g. jepa-fx"),
|
"wing": str("target wing, e.g. jepa-fx"),
|
||||||
"hall": enum("target hall", halls...),
|
"hall": enum("target hall", halls...),
|
||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
|
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +103,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"inputSchema": schema([]string{"filename"}, map[string]any{
|
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||||
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||||
"reason": str("optional; why this isn't worth promoting"),
|
"reason": str("optional; why this isn't worth promoting"),
|
||||||
|
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
noteToolDescriptors()[0],
|
noteToolDescriptors()[0],
|
||||||
@@ -383,6 +385,7 @@ type brainPromoteArgs struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||||
@@ -395,7 +398,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue,
|
||||||
Origin: origin, WrittenBy: writtenBy,
|
Origin: origin, WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -408,6 +411,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
type brainRejectArgs struct {
|
type brainRejectArgs struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||||
@@ -418,7 +422,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,3 +431,28 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
|||||||
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brain_pending lists two entries for one name when a top-level note and a
|
||||||
|
// parked one share it; the tool must let the caller say which it means.
|
||||||
|
func TestBrainPromoteAndRejectTakeTheQueueOfAnAmbiguousName(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(raw, "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "a.md"), []byte("---\ncreated_at: 2026-10-20T00:00:00Z\n---\nTOP\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "needs-review", "a.md"), []byte("---\nproposed_wing: homelab\n---\nPARKED\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts"})
|
||||||
|
require.NotNil(t, resp["error"], "an ambiguous name is refused, not guessed")
|
||||||
|
|
||||||
|
resp = toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts", "queue": "needs-review"})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/homelab/facts/a.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "PARKED")
|
||||||
|
|
||||||
|
resp = toolCall(t, srv, "brain_reject", map[string]any{"filename": "a.md", "queue": "inbox", "reason": "no"})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw/rejected/a.md"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -392,14 +392,23 @@ func topDir(rel string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none.
|
// frontmatterWing reads a top-level `wing:` from a note's leading frontmatter,
|
||||||
|
// "" if there is none. The block counts only if it has a real closing fence (a
|
||||||
|
// line that is exactly "---"), and an indented `wing:` belongs to some other
|
||||||
|
// mapping, not to the note.
|
||||||
func frontmatterWing(content string) string {
|
func frontmatterWing(content string) string {
|
||||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
if !strings.HasPrefix(content, "---\n") {
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
block, _, _ := strings.Cut(content[4:], "\n---")
|
block, _, found := cutFence(content[4:])
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
for _, line := range strings.Split(block, "\n") {
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
||||||
return strings.Trim(strings.TrimSpace(val), `"'`)
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,3 +314,24 @@ func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
|
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterWing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, in, want string
|
||||||
|
}{
|
||||||
|
{"plain key", "---\nwing: radar\n---\nbody", "radar"},
|
||||||
|
{"quoted", "---\nwing: \"radar\"\n---\nbody", "radar"},
|
||||||
|
{"CRLF", "---\r\nwing: radar\r\n---\r\nbody", "radar"},
|
||||||
|
{"no frontmatter", "wing: radar\nbody", ""},
|
||||||
|
{"an indented key belongs to another mapping", "---\nmeta:\n wing: radar\n---\nbody", ""},
|
||||||
|
{"a key before a non-fence dash line is not frontmatter", "---\nwing: radar\n----\nbody", ""},
|
||||||
|
{"no closing fence", "---\nwing: radar\nbody", ""},
|
||||||
|
{"closing fence at end of input", "---\nwing: radar\n---", "radar"},
|
||||||
|
{"another key first", "---\ntitle: x\nwing: radar\n---\nbody", "radar"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, frontmatterWing(tt.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user