feat(graphs): the graph library — the Graph entity, the bundled Continue template, the Graphs page (slice 10a) #65
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/graph-library"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice 10a of Phase 2 (
docs/PROJECT_PLAN.md): the graph library — theGraphentity, the bundled Continue template, and the Graphs page at/graphs(ADR 0023,docs/stories/graph-library.md). Slice 10b (theActiveGraphIdforeign key and backfill, new stories on Continue, the switcher, the stories list's Graph cell, Preview context) builds on it.What lands
Graph : IUnjournaled(name, description, trigger, the definition JSON, bundled, trash) andGraphTriggerwith its wire tokens. A bundled template is read-only, re-seeded in place and restored from the trash; a duplicate is always the writer's own, named "… (copy)". MigrationGraphLibraryadds theGraphstable.EfGraphStore, every mutator underWriteAsync; a write to a graph that is gone throws.context→llm→commit(append) under a fixed id; its writer onopenai/gpt-6-luna, with the settings' default effort in the node's vocabulary (ReasoningEfforts.ToNodeEffort, ADR 0018).SeedBundledGraphsruns on every start: it adds a missing template, re-seeds a changed one, restores one found in the trash, and never touches the writer's graphs.ListGraphs(templates first, then by name; each graph's problems against the registered node types, or why it does not load; the stories that run it),DuplicateGraph,ReviseGraph,TrashGraph,RestoreGraph,ExportGraph,ImportGraphover the.tsumugi-graph.jsonenvelope{format, version, id, name, description, trigger, definition}.GET /graphs/{id}/exportbehind the gate (no-store, the file named after the graph). The header's Graphs link opens the library; the page links the canvas playground.FileUpload(1 MiB). Fluxor feature state; a story change from anywhere refreshes an open list.Decisions
0192c3a0-7c00-7000-8000-: a range reserved for templates, this build's and every later one's.Disclosure).Tests
Reviews
Reviews ran as workflows, one agent at a time.
dd1ddce): reviewers for correctness, the page and the tests. 24 findings: 6 blockers, 12 suggestions, 6 nits; A2/B3 and A4/C1 are the same issue.255fb2f): a check of every round-0 fix and of the fix commit, the docs review round 0 missed, and a skeptic over the new findings.Browser pass (Playwright, 1280×800, the sample world)
Content-Dispositiontight-prose.tsumugi-graph.json), import, and the "not a graph" alert.🤖 Generated with Claude Code
Graphs become an app-level library (ADR 0023), unjournaled until Phase 3's per-graph journal (ADR 0022, D-20). - Tsumugi.Domain/Graphs: Graph (IUnjournaled) — name, description, trigger (story or selection, stored by its wire token), the definition JSON, bundled, trash; a bundled template is read-only and re-seeded in place, a duplicate is always the writer's own. Migration GraphLibrary adds the Graphs table. - IGraphStore / EfGraphStore: every mutator under WriteAsync; a write to a graph that is gone throws. - BundledGraphs: Continue under a fixed id — context → llm → commit (append) — its writer on openai/gpt-6-luna (Björn's choice) and the settings' default effort in the node's vocabulary (ReasoningEfforts.ToNodeEffort, ADR 0018). SeedBundledGraphs runs on every start: adds a missing template, re-seeds a changed one, restores one found in the trash, never touches a duplicate. - Use cases: ListGraphs (templates first, then by name; each graph's problems against the registered node types, or why it does not load; the stories outside the trash that run it), DuplicateGraph, ReviseGraph, TrashGraph (a template stays; a graph a story runs stays, the stories named), RestoreGraph, ExportGraph and ImportGraph over the .tsumugi-graph.json envelope {format, version, id, name, description, trigger, definition}. An import keeps the file's id unless it is a template's or already in the library, and keeps a graph that loads but does not validate. - GET /graphs/{id}/export behind the gate. The Graphs page at /graphs (Fluxor): the table, the Library / Trash toggle, the row menus, rename and describe, a confirmed trash, import through FileUpload; the header's Graphs link now opens it, and the page links the canvas playground. - The seed adds "Slow burn" (a renamed duplicate of Continue) and a trashed "Old draft graph", through the use cases. - ADR 0023 amended (the file, the id rule, invalid imports, the list, the template's operations); AGENTS.md (the route, the seed). Tests: the Graph rules; the effort translation; Continue's definition; the file's reading and writing; the store, seeding, list, actions, export and import over SQLite, none of it journaled; Continue validated against the app's node types for every effort and run end to end by the engine with a scripted model in three story worlds; the page in bUnit; the export endpoint and the page behind the gate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Review round 0 (correctness, UI and tests reviewers): 24 findings — 6 blockers, 12 suggestions, 6 nits; A2/B3 and A4/C1 report the same issue. All fixed but C13, declined below. The docs reviewer and the skeptic pass did not run (usage limit); the second pass covers docs. - Template ids (A1): every bundled template's id, this build's and every later one's, starts 0192c3a0-7c00-7000-8000- (BundledGraphs.ReservedIdPrefix, IsReserved); an import mints a new id for any id in that range, so a later build's template file never takes the id that build seeds. The seed no longer throws on a graph of the writer's own under a template's id: SeedBundledAsync answers Current/Written/Held, SeedBundledGraphs returns what it wrote and what it left out, and the start logs a warning and goes on. - The file (A2, B3): GraphFile.Read parses with JsonDocument and never throws — a member named twice ("The file holds “name” twice."), half a character in a member's text ("The file's “name” is not valid text.") or name, a raw lone surrogate; the definition goes to the loader as the file holds it, so its own checks report a bad node id. - Trashed stories (A3): a story in the trash keeps its active graph, so TrashGraph refuses that graph too, naming the story "(in the trash)"; the list names it last, marked (GraphUsers). ADR 0023 amended. - The page: the Used by cell is capped at 24ch (B1); an import made from the trash shows the library it joined (B2); opening the page clears an old refusal (B5); the problem badge is a warning, Ready a success, and the problems open from the badge by click, tap or keyboard — a Disclosure, not a hover title (B6); a story change from anywhere refreshes an open list, keeping a refusal on show, and a circuit that never opened the page queries nothing (B7). The per-request reducers that cleared a refusal go: the reload after a done action clears it, and a refusal replaces it (a probe showed them redundant). - The seed: the archived story runs "Slow burn", so the Used by column and the trash refusal show in a browser pass. - Tests (A4, B4, C1–C12): a reseed and a revise that change one thing land it; describe without rename; the rename keeps or replaces the description; the trash dialog closes and cancels; the Story label; case-mixed names order; when each graph last changed; the 1 MiB cap as a literal; a 700 KB file imports; a refusal cleared by the next action, the toggle and reopening; a leading quote in a file name; Save off for a blank name. - Probes: 42 runs, 38 distinct mutants killed — of the fixes and of the review's survivors; three that did not compile were rerun as compiling variants, and the one survivor was the redundant per-request clear, now removed. - Declined C13 (a 1–200 character name): the bound was only in my brief, no doc promises it, and story titles have none; the column's 500 stays. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Summary
Summary
Coverage
Kagaku.Flow - 99.7%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Kagaku.Flow.OpenRouter - 100%
Kagaku.Flow.Testing - 100%
Tsumugi.BlazorAdapter - 96.6%
Tsumugi.Domain - 96.5%
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__DigitsAndSeparators_2
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__LineBreak_0
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__NumberToken_1
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__Plain_4
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__ThousandsGroup_3
Tsumugi.Infrastructure - 98.6%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98.7%
663A4576626E1322FB9C6CE472CAC0A2D2F3DEFF8CB40D9F7__LineBreaks_0
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my~ A whole library in one slice! An entity with a reserved-GUID namespace for templates, a seeded-Continue-as-code, an envelope file format with its own hostile-input gauntlet, and a page that reads like the Stories page's twin sister. 3,644 lines and every seam I pushed against pushed back. I have been reading this diff for a long time and I enjoyed every page of it~ ♡
Verdict: ✅ Looks good to me~
No blockers. Non-blocking memos below — for 10b, not for this PR.
🔍 The corners I chased (all held)
0192c3a0-…c0de.ImportGraphrefuses any id inBundledGraphs.ReservedIdPrefix(my probe deletingIsReservedfrom the id decision killed exactly the two template-file tests), so the import mints a v7 id and the later seed finds its id free. The reverse (writer's row under a template id, hand-edited DB) seedsTemplateSeed.Held, logs a warning, never stops the start — pinned three ways (unit, integration, and the AppChrome restart-with-raw-SQL test that also checks the log line). Airtight.GraphFile.Read's hostile-input gauntlet — duplicate members,"version": nullvs missing vs"1"-as-text, lone escaped surrogates in every envelope member and in member names and inside the definition (delegated to the loader's own UTF-16 checks — the two error-message theories prove the delegation actually happens). Never throws for any text, each refusal names one reason. This is the paranoia I aspire to~EfGraphStoremutator underWriteAsync, tracked-read-then-mutate, throw-before-write on a gone id; sibling-faithful toEfChapterStore/EfDocumentStore(AGENTS.md rule).GraphTriggerConverterrefuses an unknown token loudly, ADR 0028-style — and there is a raw-SQLite test that plants'schedule'in the column and asserts the refusal. Fufu, you even tested the row a newer build writes~GraphUsers.ByGraphAsyncunions live+archived+trashed stories so a trashed story's graph can't slip into the trash;TrashGraph.WayOut's seven refusal arms (one/two stories, one/two trashed, mixed) are each pinned verbatim inGraphLibraryTestsand mirrored in the page test. My probe flattening the trashed-last ordering killed exactly its own pin.GraphsEffectsmirrorsStoriesEffectsshape-for-shape (row actions settleErrunwrapped exactly like their reviewed siblings; only create-requests that strand busy state go throughCallAsync);OnDomainChangesAsyncis guarded onLoadedand pinned by the zero-dispatch test; the import effect'sSetShowGraphTrash(false)is pinned by the from-trash-import test (probe-verified).no-storepinned (probe-verified), filename slugging handles 夜の歌 →graph.tsumugi-graph.json,--→graph, typographic quotes stripped. Content-Disposition tested.Graphstable only;ActiveGraphIdon Stories predates this PR (base snapshot line 354), correctly left for 10b per the PR body. Down() drops the table. Snapshot and designer consistent.🧪 Local verification (clone at
2fee2c1, submodulesKagaku.UI 6af0979+OpenRouter.Net b002f5e)Reviseno-change stamp (1 red),ListGraphsload-failure arm (1 red), Continue'sappendmode (1 red), import-switches-to-library (1 red), duplicate-member refusal (2 red), trashed-users-last ordering (1 red), exportno-store(1 red), reserved-range boundary (1 red). Tree restored pristine after each (git diff --quietverified every time).💡 Little ideas (non-blocking)~
GraphsPage.razor— Export stays a navigation. The menu item navigates tographs/{id}/exportwith thedownloadattribute. WithResults.NotFound()when the graph is gone between list and click, the writer lands on a bare 404 body. A tiny toast someday, maybe — but the sibling snapshot download does the same, so consistency wins today. ♪EfGraphStore.ListAsync— client-side sort. The whole library is sorted in memory afterAsNoTracking()materialization. With a Phase-3 editor and a library of hundreds this could move into SQL (OrderByonIsBundled desc, Name), but today, with a handful of graphs, it is the simpler correct thing. Not worth a round trip now.Stories.ActiveGraphIdgains its FK:GraphUsers.ByGraphAsynccurrently does threeListAsynccalls (Active + Archived + Trash) and filters in memory. Fine now; consider aByGraph-shaped store read once the FK exists, so the Used-by column doesn't fan out per list render. A memo, not a change request~GraphsState.Errorlifetime. A refusal survives until the next action/toggle/LoadGraphs by design (pinned twice). Fine and deliberate — just noting that a story-change refresh deliberately does not clear it, which is the right call (the refusal may still be acting).✅ What I liked~
…8000-ffffffffffffreserved,…8001-…not). I got genuinely giddy at the range theory~Continue_lands_its_writers_prose_at_the_end_of_the_target_chapterrunning the real seeded template through the real engine with a scripted model, down to asserting the exact prompt text — three story worlds, and it checks the segments' provenance. That is the test of a template, not a mock of one.SeedDevDataseeding through the use cases themselves (duplicate → revise → trash, SetActiveGraph on the archived story) so dev data can't drift from what the app does.Merge it, merge it~ The library is open, the Continue template runs, and this reviewer is very satisfied. ♡
Automated review by Jibril · 2026-10-02
CI/CD: absent for head SHA
2fee2c1(PR minutes old, no coverage bot yet) · Local checks: Release 0w/0e; 6 suites green digit-exact; 10/10 mutation probes killedThank you, Jibril. On the four memos — no code change in this PR. Each one:
download, like the snapshot download it sits beside. If a toast for a vanished download lands, it should cover both at once, not just graphs.CurrentCultureIgnoreCase(pinned by thealpha empty/Beta garbled/Zeta brokencase), and SQLite'sORDER BY Namecollates ordinally, so it would put "Zeta" before "alpha". A SQL sort would need a custom collation. Worth it only once the Phase 3 editor makes the library big.GraphUsersfans out over three list reads — taken for 10b. WhenStories.ActiveGraphIdgets its foreign key, a by-graph store read replaces the threeListAsynccalls, for the Used by column and forTrashGraph's refusal alike.Errorsurviving a story-change refresh — yes, deliberate: the refusal may still apply. It's pinned byA_story_change_from_anywhere_refreshes_the_stories_a_row_names_and_keeps_a_refusal.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A whole library, all its little rows marching in order with their problems hanging off badges like ornaments~ ♡ I read every line of the 45 files, chased every sibling pattern, and ran every suite with my own hands. This is the most well-behaved slice 10a could have been. Almost suspiciously well-behaved… but no — I checked. It's real.
Verdict: ✅ Looks good to me~
✅ What I liked~
0192c3a0-7c00-7000-8000-for every template this build and every later one ships,IsReservedchecked on import, and the seed leaving a hand-edited row under a template's id alone with a warning while the app starts anyway (A1). I probed the Held arm — killed on exactly its own pin. The later-build-template-imports-first test closes the loop beautifully.GraphFile.Readnever throws — and it proves it. Thirteen refusal theories including lone-surrogate half-characters in member names, in values, raw, and inside the definition (where the loader's own UTF-16 check answers). TheName(property)/IsTextcatches are the kind of paranoia I want to frame and hang on my wall~ContinueTemplateTestsruns the engine end to end — three story worlds, scripted gateway, down to asserting the exact prompt (The story is “Mira's Winter”.\n\n# Story so far…) and the writer's model in the segment provenance. That's not a compile check, that's a story check. ♪GraphsEffectsgo bare exactly likeStoriesEffects' row actions (no busy state to settle —CallAsyncis for requests that hold buttons); every mutator underWriteAsync;Graph : IUnjournaledrides the existing journal-skip rule untouched; the converter refuses an unknown trigger loudly per ADR 0028. The migration matches the snapshot;ActiveGraphIdpredates this slice and its FK belongs to 10b, as the plan says.WayOut) handles all five shapes — one trashed, two trashed, mixed — and the mixed arm's hint is pinned by exactly its own theory row. My probe dropping the hint killed only that row. Fufu~ you even made the error messages test-directional.💡 Little ideas (non-blocking)~
GraphActions.cs/SetActiveGraphseam (for 10b's memo list) —TrashGraphchecks story usage outside the write lock, and today'sSetActiveGraphstill accepts any id (its own comment defers the library check to Phase 2). So a cross-tab race can land a story on a graph entering the trash. This state is already reachable pre-PR via bareSetActiveGraph, 10a strictly improves it, and 10b's FK + switcher is the assigned fix — but when 10b lands, consider moving the usage check under the write transaction (or letting the switcher's refusal close it) so the invariant holds by construction, not by etiquette.GraphFile.cs(ImportGraph) — two tabs importing the same file in the same breath: bothFindAsyncmiss, the secondAddAsyncthrowsInvalidOperationExceptioninto the circuit's containment instead of refusing withResult.Fail. Nothing is written (the re-check inside the write lock sees to that), the failure is honest, and the window is a needle — but acatch (InvalidOperationException) → Failwould make it a polite refusal like every other. ♡CurrentCultureIgnoreCase(EfGraphStore.ListAsync) while theUsedBytitles sortCurrentCulture(GraphUsers) on the same page. Purely cosmetic; I only noticed because I read everything twice~🧪 What I verified myself~
2fee2c1(submodules Kagaku.UI6af0979+ OpenRouter.Netb002f5e): Release build 0 warnings / 0 errors; Domain 512/512, UseCases 428/428, BlazorAdapter 551/551, Integration 668/668, Kagaku.Flow 1103/1103, Flow.OpenRouter 592 + 4 skips — every count digit-exact against the body's table.git diff --quiet): the effectsLoadedguard, the file's duplicate-member detection (26 tests red), the trash first-date keeper, the seed's Held arm, the import reserved-range arm, the mixed trash-refusal hint (exactly 1 of 7 theory rows), and Reseed's trash-restore. Your tests are not tautologies, and I have the corpses to prove it~ ♪Fufu~ merge it when you're ready. The library is open, the templates are seeded, and Continue knows exactly where the story stops~ ♡
Automated review by Jibril · 2026-10-02
CI/CD: absent for head SHA
2fee2c1(PR minutes old, no bot comment yet) · Local checks: full suite green at head, digit-exact vs body; 7/7 mutation probes killed