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:
@@ -26,7 +26,7 @@ type PendingNote struct {
|
||||
// (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
|
||||
// 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"`
|
||||
ProposalReason string `json:"proposal_reason,omitempty"`
|
||||
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
||||
@@ -49,7 +49,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
out, err := listNotes(root, "")
|
||||
out, err := listNotes(root, QueueInbox)
|
||||
if err != nil {
|
||||
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).
|
||||
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
||||
defer func() { _ = sub.Close() }()
|
||||
parked, perr := listNotes(sub, "needs-review")
|
||||
parked, perr := listNotes(sub, QueueNeedsReview)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
@@ -76,9 +76,25 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
||||
}
|
||||
out := make([]PendingNote, 0, len(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)
|
||||
}
|
||||
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())
|
||||
if readErr != nil {
|
||||
continue
|
||||
@@ -95,7 +111,7 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
||||
Excerpt: excerpt(body, 200),
|
||||
Queue: queue,
|
||||
}
|
||||
if queue != "" {
|
||||
if queue == QueueNeedsReview {
|
||||
n.ProposedWing = fm.get("proposed_wing")
|
||||
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
||||
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
||||
@@ -111,6 +127,9 @@ type PromoteOptions struct {
|
||||
Wing string
|
||||
Hall string
|
||||
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
|
||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||
Origin string
|
||||
@@ -133,17 +152,26 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
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
|
||||
if slug == "" {
|
||||
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.
|
||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
raw, srcRel, err := readRawSource(brainDir, base)
|
||||
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -198,6 +226,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
type RejectOptions struct {
|
||||
Filename string // basename in brain/raw/
|
||||
Reason string // optional
|
||||
Queue string // as PromoteOptions.Queue
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
raw, srcRel, err := readRawSource(brainDir, base)
|
||||
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||
if err != nil {
|
||||
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,
|
||||
// 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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
raw, _, err := readRawNote(root, base)
|
||||
if err == nil {
|
||||
return raw, "raw/" + base, nil
|
||||
|
||||
var topRaw, parkedRaw []byte
|
||||
var topErr, parkedErr error
|
||||
if queue != QueueNeedsReview {
|
||||
topRaw, _, topErr = readRawNote(root, base)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
// brain_pending also lists notes parked in raw/needs-review/ under their
|
||||
// bare filename (hyperguild#208); the top level wins on a name clash. The
|
||||
// sub-root refuses a symlinked directory like OpenRawRoot does.
|
||||
sub, serr := root.OpenRoot(parkedDir)
|
||||
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)
|
||||
if queue != QueueInbox {
|
||||
// The sub-root refuses a symlinked directory like OpenRawRoot does.
|
||||
if sub, serr := root.OpenRoot(parkedDir); serr != nil {
|
||||
parkedErr = serr
|
||||
} else {
|
||||
parkedRaw, _, parkedErr = readRawNote(sub, base)
|
||||
_ = sub.Close()
|
||||
}
|
||||
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.
|
||||
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
|
||||
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
||||
// returned as is.
|
||||
|
||||
Reference in New Issue
Block a user