Files
hyperguild/ingestion/internal/pipeline/wing_layout_test.go
T
mathiasandClaude Sonnet 5.5 90752f2ac9
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped
fix(pipeline,api): write the source page last; say how to clear an unreadable note (review of #211, pass 4)
The source page carries source_hash, which the drift check trusts. Written first,
a batch that failed midway left a hash behind, so re-ingesting the same content
was skipped and the rest of the batch was lost for good. In a wing it is now
written last. An over-cap note in brain_pending says promote/reject cannot act
on it and to delete or move it by hand. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:00:18 +02:00

397 lines
19 KiB
Go

package pipeline
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func wingCfg(wing string, pages ...RawPage) Config {
return Config{
Wing: wing,
Schema: "# Schema",
Complete: func(context.Context, string, string) (string, error) {
return mustJSON(pages), nil
},
}
}
func sourceEntityConcept() []RawPage {
return []RawPage{
{Title: "Dark Factory Talk", Type: "source", Subtype: "video", Domain: "ai",
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
{Title: "Jev", Type: "entity", Subtype: "tool", Domain: "ai",
Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
{Title: "Dark Factory", Type: "concept", Domain: "ai",
Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
}
}
func exists(dir, rel string) bool {
_, err := os.Stat(filepath.Join(dir, rel))
return err == nil
}
func read(t *testing.T, dir, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, rel))
require.NoError(t, err)
return string(b)
}
// With a wing, the SOURCE belongs to the wing; concepts and entities are the
// shared cross-wing layer (wiki/concepts, wiki/entities) because their slug is
// the graph's global identity and they are cited from many wings.
func TestRun_WithAWingTheSourceIsPerWingAndConceptsEntitiesAreShared(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body of the talk", "dark-factory-talk", false)
require.NoError(t, err)
src := read(t, dir, "wiki/web-clip/sources/dark-factory-talk.md")
assert.Contains(t, src, "wing: web-clip")
assert.Contains(t, src, "hall: sources")
assert.True(t, exists(dir, "wiki/entities/jev.md"))
assert.True(t, exists(dir, "wiki/concepts/dark-factory.md"))
assert.NotContains(t, read(t, dir, "wiki/entities/jev.md"), "wing:", "a shared page belongs to no single wing")
assert.False(t, exists(dir, "wiki/web-clip/facts"), "no per-wing copy of a shared page")
assert.False(t, exists(dir, "wiki/sources"), "the retired source layout is not used")
}
func TestRun_WithAWingTheSharedPageLinksBackToTheSource(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "dark-factory-talk", false)
require.NoError(t, err)
assert.Contains(t, read(t, dir, "wiki/entities/jev.md"), "[[dark-factory-talk|Dark Factory Talk]]")
}
func TestRun_TheSameEntityCitedFromTwoWingsIsOnePageWithBothSources(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "first body", "talk-one", false)
require.NoError(t, err)
// A wing must already exist to be named; create it the way a note would.
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
again := []RawPage{
{Title: "Second Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nMore.\n\n## Entities Mentioned\n\n[[The Jev]]\n"},
{Title: "The Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nStill a decision model.\n\n## Sources\n"},
}
_, err = Run(context.Background(), wingCfg("homelab", again...), dir, "second body", "talk-two", false)
require.NoError(t, err)
files, err := filepath.Glob(filepath.Join(dir, "wiki/entities/*.md"))
require.NoError(t, err)
assert.Len(t, files, 1, "one entity page, matched by title with the leading article ignored")
jev := read(t, dir, "wiki/entities/jev.md")
assert.Contains(t, jev, "talk-one")
assert.Contains(t, jev, "talk-two")
assert.False(t, exists(dir, "wiki/homelab/facts/jev.md"), "no second copy in the second wing")
}
// A source slug is the graph's identity too: a second wing must not take over
// the slug of a page that lives elsewhere. Two wings dropping a file of the same
// name (notes.md, readme.md) is ordinary, so the second source gets a distinct,
// stable slug instead of failing.
func TestRun_ASourceWhoseSlugIsTakenElsewhereGetsADistinctStableSlug(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body", "talk", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"), "the first wing's page is untouched")
assert.True(t, exists(dir, "wiki/homelab/sources/talk-homelab.md"), "the second gets a wing-qualified slug")
assert.False(t, exists(dir, "wiki/homelab/sources/talk.md"))
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body, edited", "talk", false)
require.NoError(t, err)
files, _ := filepath.Glob(filepath.Join(dir, "wiki/homelab/sources/*.md"))
assert.Len(t, files, 1, "re-ingesting finds the same page, it does not make talk-homelab-2")
}
func TestRun_ASourceWhoseSlugIsTakenByARetiredPageOrASharedPageGetsADistinctSlug(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/sources"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/sources/talk.md"), []byte("---\ntitle: Talk\n---\nold"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/other.md"), []byte("---\ntitle: Other\n---\nx"), 0o644))
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/web-clip/sources/talk-web-clip.md"))
assert.Equal(t, "---\ntitle: Talk\n---\nold", read(t, dir, "wiki/sources/talk.md"), "the retired page is untouched")
}
// The other direction: a model-extracted entity or concept whose slug is a
// source's slug in some wing would lock that source out of re-ingest. The page
// is skipped with a warning; the ingest itself succeeds.
func TestRun_AnEntityWhoseSlugIsASourceElsewhereIsSkippedNotWritten(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", []RawPage{
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nA clip titled Jev.\n"},
}...), dir, "body", "jev", false)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
res, err := Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "another body", "other-talk", false)
require.NoError(t, err)
assert.False(t, exists(dir, "wiki/entities/jev.md"), "the entity would take the source's slug")
assert.Contains(t, strings.Join(res.Warnings, "\n"), "jev")
_, err = Run(context.Background(), wingCfg("web-clip", []RawPage{
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nEdited.\n"},
}...), dir, "body, edited", "jev", false)
require.NoError(t, err, "the first wing can still re-ingest its own source")
}
// The type comes from the model, so from the clip: in a wing it may only be one
// of the three the layout has a place for.
func TestRun_WithAWingAModelChosenTypeCannotWriteOutsideTheLayout(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/alpha/facts"), 0o755))
pages := append(sourceEntityConcept(),
RawPage{Title: "Planted", Type: "sources", Content: "## x\n\nplanted\n"},
RawPage{Title: "Loose", Type: "alpha", Content: "## x\n\nloose\n"},
RawPage{Title: "Sneaky", Type: "homelab", Content: "## x\n\nsneaky\n"})
res, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
require.NoError(t, err)
assert.False(t, exists(dir, "wiki/sources/planted.md"))
assert.False(t, exists(dir, "wiki/alpha/loose.md"))
assert.False(t, exists(dir, "wiki/homelab"))
assert.Contains(t, strings.Join(res.Warnings, "\n"), "invalid type")
}
func TestRun_ReIngestingTheSameSourceIntoItsOwnWingIsNotACollision(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body one", "talk", false)
require.NoError(t, err)
_, err = Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body two, edited", "talk", false)
require.NoError(t, err)
}
func TestRun_WithAWingRejectsANameThatIsNotAPlainWing(t *testing.T) {
for _, bad := range []string{"../escape", "a/b", "Radar", "has space", "."} {
t.Run(bad, func(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg(bad, sourceEntityConcept()...), dir, "body", "talk", false)
require.Error(t, err)
assert.Len(t, listAll(t, dir), 1, "nothing is written for a rejected wing (only the temp dir itself exists)")
})
}
}
func listAll(t *testing.T, dir string) []string {
t.Helper()
var out []string
_ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, _ error) error { out = append(out, p); return nil })
return out
}
// Title resolution can mask a wrong slug choice at the Run level, so the
// stability of the choice is pinned directly: the page this wing made earlier
// under the qualified slug is "its own" and is found again.
func TestResolveSourceSlug(t *testing.T) {
dir := t.TempDir()
for _, rel := range []string{
"wiki/web-clip/sources/talk.md",
"wiki/homelab/sources/talk-homelab.md",
"wiki/entities/kube.md",
} {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("---\ntitle: x\n---\n"), 0o644))
}
tests := []struct {
name, wing, base, want string
}{
{"free slug is used as is", "homelab", "fresh", "fresh"},
{"own page under the plain slug", "web-clip", "talk", "talk"},
{"taken elsewhere: wing-qualified", "tapir", "talk", "talk-tapir"},
{"own earlier qualified page is found again", "homelab", "talk", "talk-homelab"},
{"taken by a shared page", "tapir", "kube", "kube-tapir"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveSourceSlug(dir, tt.wing, tt.base)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestResolveSourceSlug_AFurtherClashGetsANumber(t *testing.T) {
dir := t.TempDir()
for _, rel := range []string{"wiki/a/sources/talk.md", "wiki/entities/talk-b.md"} {
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("x"), 0o644))
}
got, err := resolveSourceSlug(dir, "b", "talk")
require.NoError(t, err)
assert.Equal(t, "talk-b-2", got)
}
// From the second review of #211: slug clashes INSIDE one batch. dropSlugClashes
// only looked at what was already on disk, so a source and an entity extracted
// together with one slug were both written, and neither could ever be updated.
func TestRun_ASourceAndAnEntityWithOneSlugInTheSameBatchWriteOnlyTheSource(t *testing.T) {
dir := t.TempDir()
pages := []RawPage{
{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nfirst\n"},
{Title: "Notes", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity named like its source\n"},
}
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body one", "notes", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/homelab/sources/notes.md"))
assert.False(t, exists(dir, "wiki/entities/notes.md"), "two pages must not share a slug")
assert.Contains(t, strings.Join(res.Warnings, "\n"), "notes")
pages[0].Content = "## Summary\n\nfirst\n\n## Key Claims\n\n- second\n"
_, err = Run(context.Background(), wingCfg("homelab", pages...), dir, "body two, changed", "notes", false)
require.NoError(t, err)
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "second", "the source can still be updated")
}
func TestRun_AConceptAndAnEntityWithOneSlugInTheSameBatchWriteOne(t *testing.T) {
dir := t.TempDir()
pages := []RawPage{
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"},
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n"},
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
}
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body", "talk", false)
require.NoError(t, err)
written := 0
for _, rel := range []string{"wiki/concepts/foo.md", "wiki/entities/foo.md"} {
if exists(dir, rel) {
written++
}
}
assert.Equal(t, 1, written, "one slug, one page")
assert.Contains(t, strings.Join(res.Warnings, "\n"), "foo")
}
// A brain that already holds the bad pair (written before this fix) must not
// lock the source out: the source page is the note's own output and is never
// the one skipped.
func TestRun_AnExistingSourceEntityPairDoesNotLockTheSourceOut(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/sources"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/sources/notes.md"),
[]byte("---\ntitle: 'Notes'\ntype: 'note'\nwing: homelab\nhall: sources\naliases:\n - 'Notes'\n---\n\n## Summary\n\nold\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/notes.md"),
[]byte("---\ntitle: 'Notes'\naliases:\n - 'Notes'\n---\n\n## Definition\n\nstale entity\n"), 0o644))
pages := []RawPage{{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nnew text\n\n## Key Claims\n\n- fresh\n"}}
_, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "changed body", "notes", false)
require.NoError(t, err)
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "fresh", "the source took the update")
assert.Contains(t, read(t, dir, "wiki/entities/notes.md"), "stale entity", "the entity is untouched")
}
// Pass 3 of #211: a note must never leave the inbox as "processed" when nothing
// of it was written.
func TestRun_WithAWingAnIngestThatWritesNoSourcePageIsAnError(t *testing.T) {
tests := []struct {
name string
pages []RawPage
prep func(t *testing.T, dir string)
}{
{"the model returned nothing", nil, nil},
{"only pages of a type the layout has no place for", []RawPage{{Title: "Claim", Type: "claim", Content: "## x\n\ny\n"}}, nil},
{"only an entity, and its slug is taken by a source", []RawPage{{Title: "S1", Type: "entity", Subtype: "tool", Content: "## Definition\n\nx\n"}},
func(t *testing.T, dir string) {
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/s1.md"), []byte("---\ntitle: 'S1'\n---\nx"), 0o644))
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if tt.prep != nil {
tt.prep(t, dir)
}
res, err := Run(context.Background(), wingCfg("web-clip", tt.pages...), dir, "a note body", "other-note", false)
require.Error(t, err, "nothing was ingested; the caller must not archive it as done")
assert.Empty(t, res.Pages)
assert.Contains(t, err.Error(), "no source page")
})
}
}
func TestRun_WithAWingAnUnchangedSourceIsStillASkipNotAnError(t *testing.T) {
dir := t.TempDir()
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.NoError(t, err)
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.NoError(t, err)
assert.True(t, res.Skipped)
}
// Title resolution must not merge two different notes into one source page just
// because the model titled both "Home": in a wing the source path is already
// deterministic (the filename), so the title must not redirect it.
func TestRun_WithAWingTwoNotesTheModelTitlesAlikeKeepTheirOwnSourcePages(t *testing.T) {
dir := t.TempDir()
home := func(body string) []RawPage {
return []RawPage{{Title: "Home", Type: "source", Subtype: "article", Content: "## Summary\n\n" + body + "\n"}}
}
_, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
require.NoError(t, err)
_, err = Run(context.Background(), wingCfg("web-clip", home("note B text")...), dir, "body B", "note-b", false)
require.NoError(t, err)
assert.True(t, exists(dir, "wiki/web-clip/sources/note-a.md"))
assert.True(t, exists(dir, "wiki/web-clip/sources/note-b.md"), "note B has its own page")
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-a.md"), "note A text")
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-b.md"), "note B text")
res, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
require.NoError(t, err)
assert.True(t, res.Skipped, "note A's hash was not overwritten by note B")
}
// Both a concept and an entity named "Foo" arrive and the CONCEPT already exists
// on disk: the page that exists wins the tie, so its source reference is added.
func TestRun_ABatchTieBetweenAConceptAndAnEntityPrefersThePageAlreadyOnDisk(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/concepts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/concepts/foo.md"),
[]byte("---\ntitle: 'Foo'\naliases:\n - 'Foo'\n---\n\n## Definition\n\nexisting concept\n\n## Sources\n"), 0o644))
pages := []RawPage{
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n\n## Concepts Introduced or Reinforced\n\n[[Foo]]\n"},
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n\n## Sources\n"},
}
_, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
require.NoError(t, err)
assert.False(t, exists(dir, "wiki/entities/foo.md"))
assert.Contains(t, read(t, dir, "wiki/concepts/foo.md"), "talk", "the existing concept got the new source reference")
}
// Pass 4 of #211: the source page carries the hash the drift check reads. If it
// is written FIRST and a later page fails to write, the next ingest of the same
// content is skipped and the rest of the batch is lost for good. Written LAST,
// a failed batch leaves no hash and is simply redone.
func TestRun_WithAWingAFailedBatchLeavesNoSourcePageToHideBehind(t *testing.T) {
dir := t.TempDir()
// An entity path that cannot be written: a directory where the page goes.
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities/jev.md"), 0o755))
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.Error(t, err, "the entity write fails")
assert.False(t, exists(dir, "wiki/web-clip/sources/talk.md"),
"the source page (and its hash) must not exist when the batch did not complete")
require.NoError(t, os.RemoveAll(filepath.Join(dir, "wiki/entities/jev.md")))
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
require.NoError(t, err)
assert.False(t, res.Skipped, "the retry is a real ingest, not a hash skip")
assert.True(t, exists(dir, "wiki/entities/jev.md"))
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"))
}