docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m45s
CI / e2e (pull_request) Successful in 10m13s
CI / backend (pull_request) Successful in 11m27s

- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot
- docs/plans/2026-05-13-public-landing-routing-refactor.md
- docs/tutorials/build-a-page.md
- abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root)

Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and
agent .remember/ state are intentionally left untracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:59:29 -04:00
parent dc88797469
commit e5b26245ca
8 changed files with 7132 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
{
"nodes": [
{
"id": "title",
"type": "text",
"x": -860,
"y": -520,
"width": 1080,
"height": 150,
"color": "#2563eb",
"text": "# God Node Map\nResolutionFlow architecture hotspots, 2026-05-06\n\nRead left to right: behavioral risk -> expected infrastructure -> self-serve boundaries."
},
{
"id": "frontend_group",
"type": "group",
"x": -900,
"y": -300,
"width": 720,
"height": 760,
"color": "#fee2e2",
"label": "Frontend Behavioral Hubs"
},
{
"id": "assistant_page",
"type": "text",
"x": -860,
"y": -240,
"width": 300,
"height": 190,
"color": "#ef4444",
"text": "## AssistantChatPage.tsx\n\nHighest-risk frontend node.\n\n- 2,493 LOC\n- 39 outbound imports\n- 77 changes in 90 days\n- Owns many unrelated workflows"
},
{
"id": "tree_navigation_page",
"type": "text",
"x": -520,
"y": -240,
"width": 300,
"height": 160,
"color": "#f97316",
"text": "## TreeNavigationPage.tsx\n\nLarge page orchestrator.\n\n- 1,385 LOC\n- 31 outbound imports\n- 33 changes in 90 days"
},
{
"id": "procedural_navigation_page",
"type": "text",
"x": -860,
"y": 0,
"width": 300,
"height": 160,
"color": "#f97316",
"text": "## ProceduralNavigationPage.tsx\n\nLarge page orchestrator.\n\n- 1,021 LOC\n- 33 outbound imports\n- 22 changes in 90 days"
},
{
"id": "frontend_pages",
"type": "text",
"x": -520,
"y": 0,
"width": 300,
"height": 190,
"color": "#f59e0b",
"text": "## Other Page Hubs\n\n- TreeLibraryPage.tsx\n- TreeEditorPage.tsx\n- SessionDetailPage.tsx\n\nTreat as page shells. Extract workflow hooks when touched."
},
{
"id": "frontend_action",
"type": "text",
"x": -860,
"y": 250,
"width": 640,
"height": 150,
"color": "#16a34a",
"text": "## Frontend Rule\n\nDo not start a broad cleanup. For new self-serve work, keep billing in `useBillingStore`, keep onboarding state narrow, and prefer direct API module imports over the `@/api` barrel."
},
{
"id": "backend_group",
"type": "group",
"x": -80,
"y": -300,
"width": 740,
"height": 760,
"color": "#ffedd5",
"label": "Backend Behavioral Hubs"
},
{
"id": "flowpilot_engine",
"type": "text",
"x": -40,
"y": -240,
"width": 310,
"height": 190,
"color": "#ef4444",
"text": "## flowpilot_engine.py\n\nReal backend behavioral hub.\n\n- 1,793 LOC\n- prompts\n- structured parsing\n- session state transitions\n- model orchestration"
},
{
"id": "ai_sessions_endpoint",
"type": "text",
"x": 310,
"y": -240,
"width": 310,
"height": 180,
"color": "#f97316",
"text": "## ai_sessions.py\n\nController plus mapper.\n\n- 1,173 LOC\n- 15 outbound imports\n- 32 changes in 90 days\n\nKeep subscription/onboarding logic out."
},
{
"id": "sessions_trees_endpoints",
"type": "text",
"x": -40,
"y": 0,
"width": 310,
"height": 190,
"color": "#f59e0b",
"text": "## sessions.py / trees.py\n\nLarge endpoint hubs.\n\n- ownership\n- exports\n- sharing\n- limits\n- tree/session behavior\n\nUse guards and services instead of handler sprawl."
},
{
"id": "admin_endpoint",
"type": "text",
"x": 310,
"y": 0,
"width": 310,
"height": 150,
"color": "#f59e0b",
"text": "## admin.py\n\nLarge admin surface.\n\nHigh LOC, lower churn. Extend carefully, but not a self-serve blocker."
},
{
"id": "backend_action",
"type": "text",
"x": -40,
"y": 250,
"width": 660,
"height": 150,
"color": "#16a34a",
"text": "## Backend Rule\n\nMount subscription and email-verification checks at dependency/router boundaries. Keep billing behavior in BillingService and subscription models, not in AI/session/tree endpoints."
},
{
"id": "infra_group",
"type": "group",
"x": 820,
"y": -300,
"width": 640,
"height": 760,
"color": "#dbeafe",
"label": "Expected Infrastructure Hubs"
},
{
"id": "frontend_infra",
"type": "text",
"x": 860,
"y": -240,
"width": 260,
"height": 200,
"color": "#3b82f6",
"text": "## Frontend Infra\n\nExpected central nodes:\n\n- lib/utils.ts\n- lib/toast.ts\n- api/client.ts\n- types/index.ts\n- ui/Button.tsx"
},
{
"id": "backend_infra",
"type": "text",
"x": 1160,
"y": -240,
"width": 260,
"height": 200,
"color": "#3b82f6",
"text": "## Backend Infra\n\nExpected central nodes:\n\n- core/database.py\n- api/deps.py\n- core/config.py\n- ORM models"
},
{
"id": "barrel_cycles",
"type": "text",
"x": 860,
"y": 10,
"width": 260,
"height": 170,
"color": "#60a5fa",
"text": "## Barrel Cycles\n\n`frontend/src/api/*` has a large barrel/export cycle.\n\nLow urgency. Prefer direct imports in new code."
},
{
"id": "orm_cycles",
"type": "text",
"x": 1160,
"y": 10,
"width": 260,
"height": 170,
"color": "#60a5fa",
"text": "## ORM Cycles\n\nSQLAlchemy model cycles are expected.\n\nKeep behavior in services, not model methods."
},
{
"id": "infra_action",
"type": "text",
"x": 860,
"y": 250,
"width": 560,
"height": 150,
"color": "#16a34a",
"text": "## Infrastructure Rule\n\nDo not refactor a file just because it has high inbound count. Central utilities, clients, config, database, and model definitions are allowed to be central."
},
{
"id": "self_serve_group",
"type": "group",
"x": -900,
"y": 560,
"width": 2360,
"height": 300,
"color": "#dcfce7",
"label": "Self-Serve Signup Guidance"
},
{
"id": "no_blocker",
"type": "text",
"x": -860,
"y": 620,
"width": 360,
"height": 160,
"color": "#22c55e",
"text": "## Do Now\n\nNo large refactor is required before self-serve signup.\n\nUse this map to avoid accidental coupling while implementing the plans."
},
{
"id": "self_serve_boundaries",
"type": "text",
"x": -440,
"y": 620,
"width": 440,
"height": 170,
"color": "#22c55e",
"text": "## During Self-Serve\n\n- `useBillingStore`, not `authStore`\n- `BillingService`, not AI/session/tree endpoints\n- dependency guards, not repeated handler checks\n- direct API imports in new frontend code"
},
{
"id": "opportunistic_refactors",
"type": "text",
"x": 60,
"y": 620,
"width": 440,
"height": 170,
"color": "#84cc16",
"text": "## Opportunistic Refactors\n\n- Extract one Assistant workflow at a time\n- Extract FlowPilot prompt/validation pieces when touched\n- Move ai_sessions mapping helpers if touched again"
},
{
"id": "avoid_refactors",
"type": "text",
"x": 560,
"y": 620,
"width": 820,
"height": 170,
"color": "#a3e635",
"text": "## Avoid\n\n- Broad `AssistantChatPage` cleanup before product work\n- ORM cycle cleanup unless there is a runtime issue\n- Splitting utilities, toast, API client, or database just because they are central\n- Running self-serve behavior through AI/product endpoints"
}
],
"edges": [
{
"id": "edge_assistant_frontend_action",
"fromNode": "assistant_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top",
"label": "extract one workflow at a time"
},
{
"id": "edge_tree_frontend_action",
"fromNode": "tree_navigation_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top",
"label": "extract hooks when touched"
},
{
"id": "edge_proc_frontend_action",
"fromNode": "procedural_navigation_page",
"fromSide": "bottom",
"toNode": "frontend_action",
"toSide": "top"
},
{
"id": "edge_flowpilot_backend_action",
"fromNode": "flowpilot_engine",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "keep self-serve out"
},
{
"id": "edge_ai_backend_action",
"fromNode": "ai_sessions_endpoint",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "avoid billing logic here"
},
{
"id": "edge_sessions_backend_action",
"fromNode": "sessions_trees_endpoints",
"fromSide": "bottom",
"toNode": "backend_action",
"toSide": "top",
"label": "mount guards"
},
{
"id": "edge_frontend_selfserve",
"fromNode": "frontend_action",
"fromSide": "bottom",
"toNode": "self_serve_boundaries",
"toSide": "top"
},
{
"id": "edge_backend_selfserve",
"fromNode": "backend_action",
"fromSide": "bottom",
"toNode": "self_serve_boundaries",
"toSide": "top"
},
{
"id": "edge_infra_selfserve",
"fromNode": "infra_action",
"fromSide": "bottom",
"toNode": "avoid_refactors",
"toSide": "top",
"label": "do not refactor just because central"
},
{
"id": "edge_no_blocker_boundaries",
"fromNode": "no_blocker",
"fromSide": "right",
"toNode": "self_serve_boundaries",
"toSide": "left"
},
{
"id": "edge_boundaries_opportunistic",
"fromNode": "self_serve_boundaries",
"fromSide": "right",
"toNode": "opportunistic_refactors",
"toSide": "left"
},
{
"id": "edge_opportunistic_avoid",
"fromNode": "opportunistic_refactors",
"fromSide": "right",
"toNode": "avoid_refactors",
"toSide": "left"
}
]
}

View File

@@ -0,0 +1,458 @@
---
title: God Node Architecture Report
date: 2026-05-06
tags:
- architecture
- dependency-graph
- god-nodes
---
# God Node Architecture Report — 2026-05-06
## Summary
This is a static dependency and churn report for `backend/app` and `frontend/src`.
The main finding: ResolutionFlow has several expected infrastructure hubs, plus a smaller set of behavioral hubs that deserve care when touched. The highest-risk candidates are not the most-imported files; they are the files that combine high size, high churn, and many outbound dependencies.
Highest-risk behavioral hubs:
1. `frontend/src/pages/AssistantChatPage.tsx`
2. `frontend/src/pages/TreeNavigationPage.tsx`
3. `frontend/src/pages/ProceduralNavigationPage.tsx`
4. `backend/app/services/flowpilot_engine.py`
5. `backend/app/api/endpoints/ai_sessions.py`
6. `backend/app/api/endpoints/sessions.py`
7. `backend/app/api/endpoints/trees.py`
8. `backend/app/api/endpoints/admin.py`
Expected infrastructure hubs:
- `frontend/src/lib/utils.ts`
- `frontend/src/types/index.ts`
- `frontend/src/api/index.ts`
- `frontend/src/api/client.ts`
- `frontend/src/lib/toast.ts`
- `backend/app/core/database.py`
- `backend/app/api/deps.py`
- `backend/app/core/config.py`
- SQLAlchemy models such as `User`, `Tree`, `AISession`, and `Account`
Do not treat all high-degree nodes as bad. A utility, type barrel, API barrel, router, or ORM model can be central by design. The suspicious shape is: high outbound dependencies + high churn + large file + multiple unrelated reasons to change.
## Method
Inputs:
- Source files: `backend/app/**/*.py`, `frontend/src/**/*.ts`, `frontend/src/**/*.tsx`
- Excluded: tests, docs, migrations, build output, env files
- Static imports:
- Python: regex import extraction for `import ...` and `from ... import ...`
- TypeScript/TSX: static `import/export from` plus dynamic `import(...)`
- Churn: `git log --name-only --since='90 days ago'`
- Size: line count
Scoring used for triage, not truth:
```text
score = inbound_edges * 2
+ outbound_edges * 1.5
+ min(churn_90d, 30) * 1.2
+ min(lines_of_code / 100, 20)
```
Caveats:
- `backend/app/__init__.py` appears as a very high inbound node because static imports through `app.*` resolve through the package root in this simple parser. Ignore it as a parser artifact.
- Barrel files (`frontend/src/api/index.ts`, `frontend/src/types/index.ts`) intentionally create cycles with the modules they export. This is a known TypeScript graph artifact, not automatically a design flaw.
- Static graphs do not show runtime call volume. This report answers “where is the code structurally central?” not “what is hot in production?”
## Visual Map
Primary visualization:
- Open `docs/architecture/god-node-map-2026-05-06.canvas` in Obsidian.
- This uses Obsidian Canvas, so no community plugin is required.
- The Canvas groups nodes by interpretation instead of drawing every import edge.
The dense dependency graph is intentionally not the default view anymore. For architecture review, the useful split is:
1. Which nodes are high-risk behavioral hubs?
2. Which central nodes are expected infrastructure?
3. What should self-serve signup avoid touching?
### Risk Overview
```mermaid
flowchart LR
Work["Self-serve signup work"] --> Boundaries["Keep changes at boundaries"]
Boundaries --> BillingStore["useBillingStore"]
Boundaries --> Guards["router/dependency guards"]
Boundaries --> BillingService["BillingService"]
Assistant["AssistantChatPage.tsx\nfrontend god node"] -. avoid unrelated edits .-> Work
FlowPilot["flowpilot_engine.py\nbackend god node"] -. avoid unrelated edits .-> Work
AISessions["ai_sessions.py\ncontroller + mapper"] -. do not add billing logic .-> Work
SessionsTrees["sessions.py / trees.py\nlarge endpoint hubs"] -. mount guards, avoid handler sprawl .-> Work
Utils["utils / toast / api client / database\nexpected infrastructure"] -. do not refactor just because central .-> Work
```
### Frontend Hotspots
```mermaid
flowchart TB
Router["router.tsx\nroute hub"] --> Assistant["AssistantChatPage.tsx\nhighest risk"]
Router --> TreeNav["TreeNavigationPage.tsx"]
Router --> ProcNav["ProceduralNavigationPage.tsx"]
Router --> TreeLibrary["TreeLibraryPage.tsx"]
Router --> TreeEditor["TreeEditorPage.tsx"]
Router --> SessionDetail["SessionDetailPage.tsx"]
Assistant --> ExtractA["Extract one workflow at a time"]
TreeNav --> ExtractB["Extract orchestration hooks when touched"]
ProcNav --> ExtractB
Infra["utils.ts / toast.ts / api/client.ts / types/index.ts"]
Assistant --> Infra
TreeNav --> Infra
ProcNav --> Infra
```
### Backend Hotspots
```mermaid
flowchart TB
Deps["api/deps.py\nboundary hub"] --> DB["database + models\nexpected infrastructure"]
AISessions["api/endpoints/ai_sessions.py"] --> FlowPilot["services/flowpilot_engine.py"]
Sessions["api/endpoints/sessions.py"] --> Export["services/export_service.py"]
Trees["api/endpoints/trees.py"] --> DB
Admin["api/endpoints/admin.py"] --> DB
SelfServe["Self-serve backend"] --> Deps
SelfServe --> Billing["BillingService + subscriptions"]
SelfServe -. avoid .-> AISessions
SelfServe -. avoid .-> Sessions
SelfServe -. avoid .-> Trees
SelfServe -. avoid .-> FlowPilot
```
## Obsidian Visualization Options
Best default: use the generated Canvas file. Obsidian Canvas is a core plugin and stores diagrams as `.canvas` files, so it works without adding community plugin risk.
Optional plugins worth considering:
- Excalidraw: best if you want hand-edited architecture diagrams that feel like a whiteboard.
- Markmind: useful if you want this report as a mind map or outline-first view.
- Diagrams.net / draw.io plugin: useful for formal boxes-and-arrows diagrams, but heavier than Canvas for this use case.
Recommendation: start with Canvas. Add Excalidraw only if you want to manually sketch over the architecture map during planning sessions.
## Top Centrality Candidates
| Rank | File | In | Out | 90d churn | LOC | Classification | Read |
|---:|---|---:|---:|---:|---:|---|---|
| 1 | `frontend/src/lib/utils.ts` | 225 | 0 | 1 | 32 | Infrastructure hub | Good |
| 2 | `frontend/src/types/index.ts` | 137 | 32 | 22 | 103 | Barrel hub | Watch |
| 3 | `backend/app/core/database.py` | 110 | 2 | 2 | 47 | Infrastructure hub | Good |
| 4 | `backend/app/models/user.py` | 90 | 7 | 13 | 130 | Domain model hub | Watch |
| 5 | `frontend/src/api/index.ts` | 38 | 40 | 26 | 41 | API barrel hub | Watch |
| 6 | `frontend/src/lib/toast.ts` | 79 | 0 | 1 | 72 | Infrastructure hub | Good |
| 7 | `frontend/src/router.tsx` | 1 | 72 | 48 | 308 | Router hub | Watch |
| 8 | `backend/app/api/deps.py` | 56 | 9 | 13 | 292 | Auth/dependency hub | Watch |
| 9 | `backend/app/core/config.py` | 44 | 1 | 27 | 232 | Config hub | Good, but churny |
| 10 | `frontend/src/pages/AssistantChatPage.tsx` | 2 | 39 | 77 | 2493 | Behavioral hub | High risk |
| 11 | `backend/app/models/tree.py` | 43 | 10 | 11 | 233 | Domain model hub | Watch |
| 12 | `frontend/src/api/client.ts` | 51 | 2 | 5 | 173 | API client hub | Good |
| 13 | `frontend/src/pages/TreeNavigationPage.tsx` | 2 | 31 | 33 | 1385 | Behavioral hub | High risk |
| 14 | `frontend/src/components/ui/Button.tsx` | 43 | 2 | 6 | 65 | UI primitive | Good |
| 15 | `backend/app/models/ai_session.py` | 32 | 11 | 11 | 314 | Domain model hub | Watch |
| 16 | `frontend/src/pages/ProceduralNavigationPage.tsx` | 1 | 33 | 22 | 1021 | Behavioral hub | High risk |
| 17 | `frontend/src/pages/TreeLibraryPage.tsx` | 3 | 27 | 38 | 546 | Behavioral hub | Medium risk |
| 18 | `backend/app/models/account.py` | 29 | 11 | 8 | 70 | Domain model hub | Watch |
| 19 | `backend/app/api/endpoints/sessions.py` | 0 | 24 | 26 | 1186 | Endpoint hub | High risk |
| 20 | `frontend/src/pages/TreeEditorPage.tsx` | 2 | 20 | 28 | 928 | Behavioral hub | Medium risk |
| 21 | `frontend/src/pages/SessionDetailPage.tsx` | 2 | 21 | 28 | 623 | Behavioral hub | Medium risk |
| 22 | `backend/app/api/endpoints/trees.py` | 0 | 20 | 23 | 1332 | Endpoint hub | High risk |
| 23 | `backend/app/api/endpoints/ai_sessions.py` | 0 | 15 | 32 | 1173 | Endpoint hub | High risk |
| 24 | `backend/app/services/flowpilot_engine.py` | 1 | 17 | 20 | 1793 | Behavioral service hub | High risk |
## Findings
### 1. `AssistantChatPage.tsx` Is The Clearest Frontend God Node
Evidence:
- 2,493 LOC
- 39 outbound dependencies
- 77 changes in 90 days
- Owns routing, chat selection, magic-moment pickup state, task-lane state, upload state, facts, suggested fixes, preview state, script-builder surfaces, modals, keyboard shortcuts, local/session storage, and message rendering orchestration.
Classification: behavioral god node.
This file has too many reasons to change. It is not dangerous because many files import it; it is dangerous because it imports many things, owns many workflows, and changes constantly.
Recommended response:
- Do not do a broad refactor in isolation.
- When touching it, extract one workflow at a time behind a hook or controller:
- `useTaskLaneState`
- `usePilotPickup`
- `useSuggestedFixPreview`
- `useSessionFacts`
- `useScriptBuilderPanelState`
- Keep the page as an orchestrator, but move state machines and async effects out.
- Before major changes, add narrow regression tests around task-lane ownership and session switching.
Priority: high, opportunistic refactor.
### 2. `flowpilot_engine.py` Is A Real Backend Behavioral Hub
Evidence:
- 1,793 LOC
- 17 outbound dependencies
- 20 changes in 90 days
- Owns prompts, structured output parsing, session start, step generation, confidence, close/resolve/escalate behaviors, and likely several persistence transitions.
Classification: behavioral service hub.
This is not surprising: FlowPilot is core product logic. The risk is that prompt text, model call orchestration, persistence, and business rules live close together.
Recommended response:
- Keep this file stable during unrelated work.
- Extract only when a change naturally creates a seam:
- prompt construction
- structured output validation
- session state transition persistence
- documentation/status update generation
- Avoid routing new self-serve billing or account logic through this service.
Priority: high, but avoid speculative refactor.
### 3. AI Session Endpoint Is Acting As A Controller Plus Mapper
File: `backend/app/api/endpoints/ai_sessions.py`
Evidence:
- 1,173 LOC
- 15 outbound dependencies
- 32 changes in 90 days
- Contains endpoint handlers, quota checks, response mapping, ownership behavior, chat wiring, and PSA retry integration.
Classification: endpoint god node.
The endpoint does more than route HTTP to services. Some helper logic is fine, but the mapper and ownership rules should stay stable and test-backed.
Recommended response:
- Keep endpoint handlers thin when adding new features.
- Move reusable mapping logic such as `_build_session_detail` to a schema/service helper if it is touched again.
- Do not add subscription or onboarding behavior directly here; mount dependencies at router level where possible.
Priority: high for change discipline, medium for refactor.
### 4. Classic Session And Tree Endpoints Are Large, But Mostly Expected
Files:
- `backend/app/api/endpoints/sessions.py`
- `backend/app/api/endpoints/trees.py`
Evidence:
- `sessions.py`: 1,186 LOC, 24 outbound dependencies, 26 changes
- `trees.py`: 1,332 LOC, 20 outbound dependencies, 23 changes
Classification: endpoint hubs.
These files are not surprising in a CRUD-heavy FastAPI app, but they are large enough that behavioral additions should be routed through services or focused helpers.
Recommended response:
- For new subscription guards, mount dependencies instead of inserting repeated checks inside handlers.
- For new tree/session behavior, prefer service functions over adding more endpoint-local logic.
- Add regression tests before modifying export, sharing, ownership, or limit-check paths.
Priority: medium-high.
### 5. Frontend Page-Level Hubs Are The Main UI Risk
Files:
- `frontend/src/pages/TreeNavigationPage.tsx`
- `frontend/src/pages/ProceduralNavigationPage.tsx`
- `frontend/src/pages/TreeLibraryPage.tsx`
- `frontend/src/pages/TreeEditorPage.tsx`
- `frontend/src/pages/SessionDetailPage.tsx`
Pattern:
- High outbound dependencies
- Meaningful churn
- Page components own orchestration plus rendering
Recommended response:
- Treat page components as shells where possible.
- Extract stable workflow hooks before adding another workflow.
- Keep design updates scoped to subcomponents.
- Avoid adding global state unless the state truly spans routes.
Priority: medium, with `TreeNavigationPage.tsx` and `ProceduralNavigationPage.tsx` highest.
### 6. Auth Store Is Central But Not Yet A Problem
File: `frontend/src/store/authStore.ts`
Evidence:
- 21 inbound dependencies
- 5 outbound dependencies
- 144 LOC
- 6 changes in 90 days
Classification: central state hub.
This is a normal app hub. It becomes risky if billing, onboarding, feature gates, and auth all accumulate here. The self-serve specs choice to create `useBillingStore` instead of embedding billing state in `/auth/me` is the right architectural direction.
Recommended response:
- Keep auth store focused on identity/session/account bootstrap.
- Put billing in `useBillingStore`.
- Put onboarding wizard state in a narrow API/hook, not in auth.
Priority: watch.
### 7. Barrels Are Creating A Large Frontend Cycle
Cycle:
- 42 files under `frontend/src/api/*`
- Driven by `frontend/src/api/index.ts` exporting modules while some modules import from the barrel or share `apiClient`.
Classification: barrel cycle / tooling artifact with some real coupling risk.
This is common and not urgent. It can confuse static tools and make imports less explicit.
Recommended response:
- Prefer direct imports from concrete API modules in new code:
- Good: `import { aiSessionsApi } from '@/api/aiSessions'`
- Avoid: `import { aiSessionsApi } from '@/api'`
- Keep `api/index.ts` only for broad convenience if it remains useful.
- Do not spend time untangling old imports unless dependency tooling starts enforcing boundaries.
Priority: low.
### 8. Backend ORM Model Cycles Are Expected
Cycle:
- 17 files across account/user/tree/session/subscription/category/share models
- 5 files across AI session branch/handoff/step models
Classification: SQLAlchemy relationship cycle.
This is expected in an ORM with bidirectional relationships. It does not mean the model layer is broken.
Recommended response:
- Keep imports guarded with `TYPE_CHECKING` where possible.
- Keep model methods thin.
- Put behavior in services, not model properties beyond simple derived flags.
Priority: low.
## Ranked Action List
### Do Now
No immediate large refactor is recommended before self-serve signup work. The report does not show a blocker.
### Do During Self-Serve Work
1. Keep `useBillingStore` separate from `authStore`.
2. Mount subscription and email verification guards at router/dependency boundaries, not inside individual endpoint handlers.
3. Keep new billing service behavior out of existing `ai_sessions.py`, `sessions.py`, and `trees.py` except for dependency wiring.
4. Prefer direct frontend API imports over `@/api` barrel imports in new code.
### Do Opportunistically
1. Extract one workflow at a time from `AssistantChatPage.tsx`.
2. Extract prompt construction or structured response validation from `flowpilot_engine.py` when touched.
3. Move response mapping helpers out of `ai_sessions.py` if those helpers change again.
4. Split page-level orchestration hooks out of `TreeNavigationPage.tsx` and `ProceduralNavigationPage.tsx` as features touch them.
### Avoid
1. Do not split `utils.ts`, `toast.ts`, `api/client.ts`, or `core/database.py` just because they are central.
2. Do not refactor ORM model cycles unless they cause import/runtime issues.
3. Do not start a broad barrel-file cleanup unless tooling or build performance requires it.
## Raw Metrics Snapshot
Total analyzed files: 783
Total static import edges: 2,946
Top inbound hubs:
| File | Inbound | Outbound | 90d churn | LOC | Note |
|---|---:|---:|---:|---:|---|
| `frontend/src/lib/utils.ts` | 225 | 0 | 1 | 32 | Healthy utility hub |
| `frontend/src/types/index.ts` | 137 | 32 | 22 | 103 | Barrel hub |
| `backend/app/core/database.py` | 110 | 2 | 2 | 47 | Healthy infrastructure hub |
| `backend/app/models/user.py` | 90 | 7 | 13 | 130 | Domain model hub |
| `frontend/src/lib/toast.ts` | 79 | 0 | 1 | 72 | Healthy utility hub |
| `backend/app/api/deps.py` | 56 | 9 | 13 | 292 | Auth/dependency hub |
| `frontend/src/api/client.ts` | 51 | 2 | 5 | 173 | API infrastructure hub |
| `backend/app/core/config.py` | 44 | 1 | 27 | 232 | Config hub, high churn |
| `backend/app/models/tree.py` | 43 | 10 | 11 | 233 | Domain model hub |
| `frontend/src/components/ui/Button.tsx` | 43 | 2 | 6 | 65 | UI primitive |
Top outbound hubs:
| File | Inbound | Outbound | 90d churn | LOC | Note |
|---|---:|---:|---:|---:|---|
| `frontend/src/router.tsx` | 1 | 72 | 48 | 308 | Router hub, acceptable |
| `frontend/src/api/index.ts` | 38 | 40 | 26 | 41 | Barrel hub |
| `frontend/src/pages/AssistantChatPage.tsx` | 2 | 39 | 77 | 2493 | High-risk behavioral hub |
| `frontend/src/pages/ProceduralNavigationPage.tsx` | 1 | 33 | 22 | 1021 | High-risk behavioral hub |
| `frontend/src/pages/TreeNavigationPage.tsx` | 2 | 31 | 33 | 1385 | High-risk behavioral hub |
| `frontend/src/pages/TreeLibraryPage.tsx` | 3 | 27 | 38 | 546 | Medium-risk page hub |
| `backend/app/api/endpoints/sessions.py` | 0 | 24 | 26 | 1186 | High-risk endpoint hub |
| `backend/app/api/endpoints/admin.py` | 0 | 22 | 10 | 1430 | Admin endpoint hub |
| `frontend/src/pages/SessionDetailPage.tsx` | 2 | 21 | 28 | 623 | Medium-risk page hub |
| `backend/app/api/endpoints/auth.py` | 0 | 20 | 9 | 721 | Auth endpoint hub |
| `backend/app/api/endpoints/trees.py` | 0 | 20 | 23 | 1332 | High-risk endpoint hub |
| `frontend/src/pages/ProceduralEditorPage.tsx` | 1 | 20 | 16 | 475 | Medium-risk page hub |
| `frontend/src/pages/TreeEditorPage.tsx` | 2 | 20 | 28 | 928 | Medium-risk page hub |
Detected cycles:
| Size | Area | Interpretation |
|---:|---|---|
| 42 | `frontend/src/api/*` | Barrel/export cycle. Low urgency. |
| 17 | backend ORM models | Expected SQLAlchemy relationship cycle. Low urgency. |
| 5 | backend AI session models | Expected relationship cycle. Low urgency. |
| 2 | tree preview components | Small component cycle; inspect only if these files become troublesome. |
## How To Re-run
The current environment does not have native Python, so this report was generated with Node-based static parsing plus shell/git commands. A future repeat can use a dedicated script if this becomes a regular architecture check.
Suggested future command shape:
```bash
node scripts/architecture/god-node-report.mjs
```
If this becomes a recurring check, add:
- `scripts/architecture/god-node-report.mjs`
- `docs/architecture/god-node-report-YYYY-MM-DD.md`
- optional `docs/architecture/god-node-graph-YYYY-MM-DD.mmd`

View File

@@ -0,0 +1,523 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResolutionFlow — Workflow Analysis</title>
<style>
:root {
--bg-page: #0e1016;
--bg-card: #1e2028;
--bg-elev: #2a2d38;
--border: #2a2d38;
--border-hover: #3a3d48;
--text-heading: #f4f5f7;
--text-primary: #d6d8df;
--text-muted: #8b8e98;
--text-dim: #5a5d68;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.15);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.12);
--info: #67e8f9;
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.12);
--danger: #f87171;
--danger-dim: rgba(248, 113, 113, 0.12);
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-page);
color: var(--text-primary);
font-family: var(--sans);
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 880px;
margin: 0 auto;
padding: 48px 32px 80px;
}
header.page {
border-bottom: 1px solid var(--border);
padding-bottom: 24px;
margin-bottom: 32px;
}
header.page .eyebrow {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 8px;
}
header.page h1 {
font-family: var(--heading);
font-weight: 700;
font-size: 32px;
line-height: 1.15;
color: var(--text-heading);
margin: 0 0 12px;
letter-spacing: -0.015em;
}
header.page .meta {
color: var(--text-muted);
font-size: 13px;
}
header.page .meta a { color: var(--accent); text-decoration: none; }
header.page .meta a:hover { text-decoration: underline; }
h2 {
font-family: var(--heading);
font-weight: 700;
font-size: 22px;
color: var(--text-heading);
margin: 48px 0 14px;
letter-spacing: -0.01em;
}
h3 {
font-family: var(--heading);
font-weight: 600;
font-size: 17px;
color: var(--text-heading);
margin: 28px 0 8px;
letter-spacing: -0.005em;
}
p { margin: 0 0 12px; }
ul { margin: 0 0 16px; padding-left: 22px; }
ul li { margin-bottom: 6px; }
a { color: var(--accent); }
code, kbd {
font-family: var(--mono);
font-size: 12px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.04);
padding: 1px 6px;
border-radius: 3px;
color: var(--text-primary);
}
strong { color: var(--text-heading); font-weight: 600; }
em { color: var(--text-primary); font-style: italic; }
.tldr {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 20px 24px;
margin-bottom: 36px;
}
.tldr h2 { margin: 0 0 8px; font-size: 14px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); }
.tldr p { margin: 0 0 10px; font-size: 15px; line-height: 1.55; }
.tldr p:last-child { margin-bottom: 0; }
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin: 20px 0 8px;
}
.metric {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 16px;
}
.metric .label {
font-family: var(--mono);
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.metric .value {
font-family: var(--heading);
font-weight: 700;
font-size: 26px;
color: var(--text-heading);
line-height: 1.1;
}
.metric .sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
table.data {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 13px;
}
table.data th, table.data td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
table.data th {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
background: var(--bg-card);
}
table.data td.num { font-family: var(--mono); text-align: right; }
table.data td.kind { font-family: var(--mono); font-size: 12px; }
table.data td .pill {
display: inline-block;
padding: 1px 7px;
border-radius: 3px;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
td .pill.yes { background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(251,191,36,0.3); }
td .pill.no { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
td .pill.maybe { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(96,165,250,0.3); }
/* Heatmap */
.heatmap {
margin: 16px 0;
border-collapse: collapse;
font-family: var(--mono);
font-size: 11px;
}
.heatmap th, .heatmap td {
border: 1px solid var(--border);
padding: 8px 6px;
text-align: center;
min-width: 44px;
color: var(--text-muted);
}
.heatmap th {
background: var(--bg-card);
color: var(--text-muted);
text-transform: lowercase;
font-weight: 600;
letter-spacing: 0.04em;
}
.heatmap td.label {
text-align: right;
background: var(--bg-card);
color: var(--text-muted);
font-weight: 600;
padding-right: 10px;
}
.heatmap td.empty { color: var(--text-dim); }
.heatmap td.diag { color: var(--text-dim); background: rgba(255,255,255,0.015); }
.heatmap td.h1 { color: var(--text-heading); background: rgba(96,165,250,0.1); }
.heatmap td.h2 { color: var(--text-heading); background: rgba(96,165,250,0.22); font-weight: 700; }
.heatmap td.h3 { color: var(--bg-page); background: var(--accent); font-weight: 700; }
.heatmap-caption { color: var(--text-muted); font-size: 12px; margin: 8px 0 16px; }
.concern {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 18px 22px;
margin: 14px 0;
}
.concern.warning { border-left: 3px solid var(--warning); }
.concern.info { border-left: 3px solid var(--accent); }
.concern.success { border-left: 3px solid var(--success); }
.concern h3 { margin-top: 0; display: flex; align-items: baseline; gap: 10px; }
.concern h3 .tag {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 7px;
border-radius: 3px;
}
.concern.warning h3 .tag { background: var(--warning-dim); color: var(--warning); }
.concern.info h3 .tag { background: var(--accent-dim); color: var(--accent); }
.concern.success h3 .tag { background: var(--success-dim); color: var(--success); }
.concern p:last-child { margin-bottom: 0; }
.caveat {
background: rgba(255,255,255,0.025);
border-left: 2px solid var(--text-dim);
padding: 12px 18px;
margin: 16px 0;
font-size: 13px;
color: var(--text-muted);
}
.caveat strong { color: var(--text-primary); }
footer.page {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
@media (max-width: 720px) {
.container { padding: 32px 20px; }
.metrics { grid-template-columns: repeat(2, 1fr); }
header.page h1 { font-size: 26px; }
.heatmap { font-size: 10px; }
.heatmap th, .heatmap td { padding: 6px 4px; min-width: 32px; }
}
</style>
</head>
<body>
<div class="container">
<header class="page">
<div class="eyebrow">Architecture review · 2026-05-13</div>
<h1>ResolutionFlow workflow analysis</h1>
<div class="meta">
Based on <a href="workflows.html">workflows.html</a> · 28 user-facing flows · 297 traced steps · 120 unique files
</div>
</header>
<div class="tldr">
<h2>Bottom line</h2>
<p>You're <strong>not bloated</strong>, and most of the "circles" in the diagram are <strong>visualization artifact, not architecture problems</strong>. Each HTTP call shows up as two steps (request + response), so a normal round-trip <em>looks</em> like a circle even though it's one unit of work.</p>
<p>Three real items worth engineering attention: <code>ai_sessions.py</code> is becoming a god endpoint, the three chat services have a confusing boundary, and the auth token tables have no physical cleanup so they accrue rows forever. Everything else looks structurally healthy.</p>
</div>
<h2>Headline numbers</h2>
<div class="metrics">
<div class="metric">
<div class="label">Avg steps / flow</div>
<div class="value">10.6</div>
<div class="sub">healthy range for multi-tenant SaaS</div>
</div>
<div class="metric">
<div class="label">Avg files / flow</div>
<div class="value">7.5</div>
<div class="sub">one file per layer, roughly</div>
</div>
<div class="metric">
<div class="label">Revisit ratio</div>
<div class="value">1.39</div>
<div class="sub">1.0 = flat; 2.0+ = chat-shaped</div>
</div>
<div class="metric">
<div class="label">"Backward" edges</div>
<div class="value">15%</div>
<div class="sub">mostly HTTP response, not real circles</div>
</div>
</div>
<h2>Why the diagrams look circular</h2>
<p>Each HTTP request and its response are encoded as <strong>two separate steps</strong>. So an API call architecturally goes <em>one direction</em>, but visually looks like a loop. Breakdown of the 44 backward-flowing edges:</p>
<table class="data">
<thead>
<tr><th>Kind</th><th class="num">Count</th><th>Real circle?</th><th>Example</th></tr>
</thead>
<tbody>
<tr>
<td class="kind">http_post / http_get response</td>
<td class="num">20</td>
<td><span class="pill no">artifact</span></td>
<td>Server returns 200 to client. Not a circle.</td>
</tr>
<tr>
<td class="kind">function_call return value</td>
<td class="num">8</td>
<td><span class="pill no">artifact</span></td>
<td><code>oauth_providers</code> returns an <code>OAuthProfile</code> to the endpoint that called it.</td>
</tr>
<tr>
<td class="kind">state_update (hook → component/page)</td>
<td class="num">8</td>
<td><span class="pill maybe">idiomatic</span></td>
<td>Hook returns updated state, page re-renders. Pure React data flow.</td>
</tr>
<tr>
<td class="kind">redirect (OAuth provider → app)</td>
<td class="num">4</td>
<td><span class="pill yes">real</span></td>
<td>Google/Microsoft sends user back to <code>/oauth/callback</code>. Architecturally required.</td>
</tr>
<tr>
<td class="kind">webhook</td>
<td class="num">1</td>
<td><span class="pill yes">real</span></td>
<td>Stripe POSTs to <code>/webhooks/stripe</code>. External system re-enters us.</td>
</tr>
<tr>
<td class="kind">navigation / external_api / other</td>
<td class="num">3</td>
<td><span class="pill yes">real</span></td>
<td>Page-to-page nav, Anthropic returning a response.</td>
</tr>
</tbody>
</table>
<p>After subtracting the request/response duality, the <em>real</em> backward edges are about <strong>3% of steps</strong>, and every one of them is in a place where the architecture demands it (React state propagation, OAuth callbacks, webhooks).</p>
<h2>What's healthy</h2>
<div class="concern success">
<h3>Clean layer discipline <span class="tag">good</span></h3>
<p>The system mostly respects layer boundaries. <code>endpoint → service</code> (34x), <code>service → external</code> (37x), <code>api_client → endpoint</code> (30x) dominate the traffic. Things flow in the expected direction.</p>
</div>
<div class="concern success">
<h3><code>flowpilot_engine</code> is the right kind of shared service <span class="tag">good</span></h3>
<p>Touched by 5 flows (start, respond, resolve, pause, abandon). That's a coordination kernel doing its job — high fan-in is correct for orchestration code.</p>
</div>
<div class="concern success">
<h3>PostgreSQL in 25/28 flows <span class="tag">good</span></h3>
<p>Star topology, not a tangle. That's what a database is supposed to look like.</p>
</div>
<h2>Layer transition heatmap</h2>
<p>How many times each layer-pair appears across all steps. Bright cells = well-traveled paths. Empty cells = layer boundaries that aren't crossed (mostly a good sign).</p>
<table class="heatmap">
<thead>
<tr>
<th></th>
<th>page</th><th>comp</th><th>hook</th><th>store</th><th>api_c</th><th>http</th><th>endp</th><th>serv</th><th>core</th><th>model</th><th>ext</th>
</tr>
</thead>
<tbody>
<tr><td class="label">page</td> <td class="diag">13</td><td class="h1">5</td> <td class="h1">6</td><td class="h1">12</td><td class="h2">17</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>2</td></tr>
<tr><td class="label">comp</td> <td>1</td> <td class="diag">5</td><td>2</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
<tr><td class="label">hook</td> <td class="h1">7</td><td>1</td> <td class="diag empty">·</td><td class="empty">·</td><td class="h2">11</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
<tr><td class="label">store</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag">4</td><td>2</td><td class="empty">·</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td></tr>
<tr><td class="label">api_client</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>5</td><td class="h3">30</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td></tr>
<tr><td class="label">endpoint</td> <td>3</td><td class="empty">·</td><td class="h1">9</td><td>2</td><td>4</td><td class="empty">·</td><td class="diag">1</td><td class="h3">34</td><td class="h1">8</td><td>2</td><td class="h3">29</td></tr>
<tr><td class="label">service</td> <td>1</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>2</td><td class="empty">·</td><td>3</td><td class="diag h1">9</td><td>5</td><td>4</td><td class="h3">37</td></tr>
<tr><td class="label">core</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td class="empty">·</td><td>4</td></tr>
<tr><td class="label">model</td> <td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>1</td></tr>
<tr><td class="label">external</td> <td>4</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td>1</td><td>1</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td></tr>
<tr><td class="label">http_client</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="diag empty">·</td><td>5</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td><td class="empty">·</td></tr>
</tbody>
</table>
<p class="heatmap-caption">Read row → column. Diagonal = same-layer transitions. Above-diagonal = "backward" (e.g. <code>endpoint → hook</code> = HTTP response). The strong upper-right concentration (<code>endpoint → service → external</code>) is the right shape.</p>
<h2>Top coupling hot-spots</h2>
<p>Files appearing in the most flows. The first two (PostgreSQL, Anthropic) are expected; everything else is worth a glance.</p>
<table class="data">
<thead>
<tr><th class="num">Flows</th><th>File</th><th>Layer</th><th>Read</th></tr>
</thead>
<tbody>
<tr><td class="num">25</td><td><code>external:postgres</code></td><td>external</td><td>Expected. The DB is the hub.</td></tr>
<tr><td class="num">10</td><td><code>external:anthropic_api</code></td><td>external</td><td>Expected for an AI product.</td></tr>
<tr><td class="num"><strong>7</strong></td><td><code>backend/app/api/endpoints/ai_sessions.py</code></td><td>endpoint</td><td><strong>God endpoint candidate.</strong> See concern below.</td></tr>
<tr><td class="num">6</td><td><code>frontend/src/api/aiSessions.ts</code></td><td>api_client</td><td>Mirrors the god endpoint. Splits naturally if backend splits.</td></tr>
<tr><td class="num">5</td><td><code>backend/app/services/flowpilot_engine.py</code></td><td>service</td><td>Healthy coordination kernel.</td></tr>
<tr><td class="num">5</td><td><code>backend/app/api/endpoints/auth.py</code></td><td>endpoint</td><td>5 auth flows, 5 endpoints. Reasonable.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/store/authStore.ts</code></td><td>store</td><td>Centralized auth state. Correct.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/pages/FlowPilotSessionPage.tsx</code></td><td>page</td><td>Worth checking — see OAuth concern.</td></tr>
<tr><td class="num">5</td><td><code>frontend/src/hooks/useFlowPilotSession.ts</code></td><td>hook</td><td>Always co-travels with the page. Right pattern.</td></tr>
</tbody>
</table>
<h2>Things worth examining</h2>
<div class="concern warning">
<h3>1. <code>ai_sessions.py</code> is a god endpoint <span class="tag">split candidate</span></h3>
<p>Appears in 7 flows. Houses ~12 route handlers in one file: <code>create</code>, <code>respond</code>, <code>chat</code>, <code>resolve</code>, <code>escalate</code>, <code>pause</code>, <code>abandon</code>, <code>pickup</code>, <code>list</code>, <code>get</code>, plus the <code>/chat</code> + <code>/respond</code> overload. It's the highest-coupled non-DB node.</p>
<p>Suggested seam:</p>
<ul>
<li><code>session_lifecycle.py</code> — create, resolve, escalate, pause, abandon, pickup</li>
<li><code>session_messaging.py</code> — chat, respond</li>
</ul>
<p>Frontend <code>aiSessions.ts</code> would split along the same line. Net change: clearer ownership, no functional impact.</p>
</div>
<div class="concern warning">
<h3>2. Three chat services with a confusing boundary <span class="tag">name vs reality</span></h3>
<p>Three files exist with overlapping responsibilities:</p>
<ul>
<li><code>backend/app/services/unified_chat_service.py</code> — chat session handling, marker parsing</li>
<li><code>backend/app/services/assistant_chat_service.py</code><code>_call_ai</code> infrastructure (Anthropic with caching, MCP, vision)</li>
<li><code>backend/app/core/ai_chat_service.py</code> — flow-builder chat for editors (separate domain)</li>
</ul>
<p>The <code>PROJECT_CONTEXT.md</code> note says <code>assistant_chat_service</code> was "removed except for retention settings," but the trace shows <code>unified_chat_service.send_chat_message</code> still calls into it for <code>_call_ai</code>. So the file is load-bearing infrastructure, not retention scaffolding.</p>
<p>Two paths forward:</p>
<ul>
<li>Rename <code>assistant_chat_service.py</code><code>ai_call_utils.py</code> (or fold the <code>_call_ai</code> function into <code>core/ai_provider.py</code> where the provider abstraction already lives).</li>
<li>Update <code>PROJECT_CONTEXT.md</code> to match reality.</li>
</ul>
<p>Either way the confusing seam goes away.</p>
</div>
<div class="concern warning">
<h3>3. OAuth login is the most "circular" real flow <span class="tag">overloaded callback</span></h3>
<p>19 steps, 4 backward edges, 3 self-loops — by far the most complex auth flow. Some complexity is unavoidable (provider redirect = 2 boundary crossings). But 3 self-loops on <code>OAuthCallbackPage</code> suggest the page is doing too much local state shuffling: CSRF state validation, code exchange, invite-code stash retrieval, JWT storage, navigation, welcome-banner logic.</p>
<p>Worth a look: move OAuth state handling into either <code>authStore</code> (which would centralize all auth state in one place) or a <code>useOAuthCallback</code> hook. The page itself should be mostly declarative.</p>
</div>
<div class="concern warning">
<h3>4. Three auth-token tables grow without bound <span class="tag">add cleanup</span></h3>
<p>Auth writes to <code>refresh_tokens</code>, <code>password_reset_tokens</code>, <code>email_verification_tokens</code>, and <code>oauth_identities</code>. Each table is individually justified (different lifecycles, different lookup patterns, JTI rotation for refresh) — <strong>this is not bloat in the code</strong>. But the cleanup story is missing.</p>
<p>Verified directly: <code>retention_cleanup.py</code> only sweeps <code>AssistantChat</code>. <code>scheduler.py</code> only has one other cleanup job, for <code>AIConversation</code>. The auth endpoint code in <code>auth.py</code> <em>revokes</em> tokens (<code>UPDATE … SET revoked_at = now()</code>) but never <em>deletes</em> them. So:</p>
<ul>
<li><code>refresh_tokens</code> — revoked rows stay forever. One row per login + one per refresh rotation.</li>
<li><code>password_reset_tokens</code> — one row per forgot-password request, no cleanup at all.</li>
<li><code>email_verification_tokens</code> — one row per signup (and per re-send), no cleanup.</li>
<li><code>oauth_identities</code> — correctly persistent; this is a permanent FK from user to provider, not a cleanup target.</li>
</ul>
<p>Suggested fix: add a daily APScheduler job in <code>retention_cleanup.py</code> (or a sibling) that hard-deletes rows where <code>revoked_at &lt; now() - INTERVAL '30 days'</code> for <code>refresh_tokens</code>, and <code>expires_at &lt; now() - INTERVAL '7 days'</code> for the two single-use token tables. Pattern matches the existing <code>cleanup_expired_chats</code> shape and the <code>_cleanup_expired_ai_conversations</code> job in <code>scheduler.py</code>.</p>
<p class="heatmap-caption">Earlier draft of this concern pointed to <code>retention_cleanup.py</code> as the place to <em>verify</em> existing cleanup. That was wrong — no such cleanup exists. Corrected after direct check.</p>
</div>
<h2>Things <em>not</em> to worry about</h2>
<div class="concern success">
<h3>Hook ↔ page state loops in session flows</h3>
<p>That's just React. <code>useFlowPilotSession</code> and <code>FlowPilotSessionPage</code> always travel together because the hook <em>is</em> that page's controller — they're maximally coupled by design, which is the right pattern.</p>
</div>
<div class="concern success">
<h3>Low "work percentage" on simple flows</h3>
<p>"Pause &amp; leave" comes out at 11% real work, 89% plumbing. That's correct — pause is structurally just <code>PATCH status='paused'</code>. There's no work to do beyond plumbing. The metric undersells simple flows.</p>
</div>
<div class="concern success">
<h3>The 25-flow PostgreSQL hub</h3>
<p>Star topology, not a tangle. A database serving every flow is the architectural ideal.</p>
</div>
<h2>Caveats on this analysis</h2>
<div class="caveat">
<strong>Work vs plumbing heuristic undersells reality.</strong> It counts <code>http_post</code> as plumbing even when it carries the actual payload. Work percentages should be read as <em>roughly 2x</em> the displayed value.
</div>
<div class="caveat">
<strong>Only user-facing flows are traced.</strong> Background work (knowledge flywheel scheduler, retention cleanup, PSA retry scheduler, MCP turn routing) isn't in here — and that's exactly where bloat tends to hide because nobody watches it. A follow-up trace of the background jobs would close the loop.
</div>
<div class="caveat">
<strong>~6 of 297 steps marked <code>unverified</code></strong> (mostly knowledge-flywheel-created proposals). They're included in the totals but the conclusions don't depend on them.
</div>
<div class="caveat">
<strong>"Backward edge" includes HTTP responses.</strong> An HTTP round-trip looks like one forward step (request) plus one backward step (response). That alone accounts for the majority of the 15% backward share. The interesting backward edges are the ~3% that aren't request/response duality.
</div>
<footer class="page">
Generated from <code>workflows.json</code> · 28 user-facing flows · 297 steps · 120 files · ResolutionFlow 2026-05-13
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,807 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResolutionFlow — Workflow Diagram</title>
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elev: #2a2d38;
--border: #2a2d38;
--border-hover: #3a3d48;
--text-heading: #f4f5f7;
--text-primary: #d6d8df;
--text-muted: #8b8e98;
--text-dim: #5a5d68;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.15);
--warning: #fbbf24;
--info: #67e8f9;
--success: #34d399;
--danger: #f87171;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif;
--heading: "Bricolage Grotesque", "IBM Plex Sans", sans-serif;
/* layer colors */
--layer-page: #a78bfa;
--layer-component: #60a5fa;
--layer-hook: #38bdf8;
--layer-store: #22d3ee;
--layer-api_client: #34d399;
--layer-http_client: #6ee7b7;
--layer-endpoint: #fbbf24;
--layer-service: #fb923c;
--layer-core: #f87171;
--layer-model: #ec4899;
--layer-db: #c084fc;
--layer-external: #94a3b8;
--layer-unknown: #6b7280;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: var(--sans);
font-size: 13px;
line-height: 1.5;
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 280px 1fr 380px;
grid-template-rows: 52px 1fr;
height: 100vh;
}
header {
grid-column: 1 / -1;
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 16px;
}
header h1 {
margin: 0;
font-family: var(--heading);
font-weight: 700;
font-size: 16px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
header .badge {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
}
header .spacer { flex: 1; }
header .meta { font-size: 11px; color: var(--text-muted); }
header a { color: var(--accent); text-decoration: none; }
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 12px 0;
}
.sidebar .group {
padding: 8px 16px 4px;
font-family: var(--heading);
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 8px;
}
.sidebar .group:first-child { margin-top: 0; }
.sidebar button.flow-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: transparent;
border: 0;
border-left: 2px solid transparent;
color: var(--text-primary);
font-family: var(--sans);
font-size: 13px;
text-align: left;
padding: 7px 14px 7px 14px;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.sidebar button.flow-item:hover {
background: var(--bg-card);
color: var(--text-heading);
}
.sidebar button.flow-item.active {
background: var(--accent-dim);
border-left-color: var(--accent);
color: var(--text-heading);
}
.sidebar button.flow-item .count {
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
}
.sidebar button.flow-item.active .count { color: var(--accent); }
.canvas-wrap {
background: var(--bg-page);
overflow: auto;
position: relative;
}
.canvas-wrap .placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
text-align: center;
padding: 24px;
}
.canvas-wrap .placeholder h2 {
font-family: var(--heading);
color: var(--text-heading);
margin: 0 0 8px;
}
.canvas-wrap .placeholder p { max-width: 420px; margin: 4px 0; }
svg.graph { display: block; background: var(--bg-page); }
.layer-header {
fill: var(--text-muted);
font-family: var(--heading);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.column-band { fill: rgba(255,255,255,0.012); }
.node rect {
fill: var(--bg-card);
stroke: var(--border-hover);
stroke-width: 1;
rx: 6;
}
.node.in-flow rect { stroke-width: 1.5; }
.node text.label {
fill: var(--text-primary);
font-family: var(--mono);
font-size: 11px;
dominant-baseline: middle;
}
.node text.sublabel {
fill: var(--text-dim);
font-family: var(--sans);
font-size: 10px;
dominant-baseline: middle;
}
.node .layer-pill {
rx: 2;
height: 4;
}
.edge {
fill: none;
stroke: #4a5061;
color: #4a5061;
stroke-width: 1.25;
opacity: 0.55;
transition: opacity 0.18s, stroke-width 0.18s, stroke 0.18s, color 0.18s;
}
.edge.has-active { opacity: 0.15; }
.edge.highlight {
stroke-width: 2.5;
opacity: 1;
stroke: var(--warning);
color: var(--warning);
}
.edge-num {
cursor: pointer;
transition: opacity 0.18s;
}
.edge-num.dim { opacity: 0.35; }
.edge-num-bg {
fill: var(--bg-card);
stroke: var(--accent);
stroke-width: 1.25;
transition: fill 0.18s, stroke 0.18s, r 0.18s;
}
.edge-num.highlight .edge-num-bg {
fill: var(--warning);
stroke: var(--warning);
}
.edge-num-text {
fill: var(--accent);
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
transition: fill 0.18s;
}
.edge-num.highlight .edge-num-text {
fill: var(--bg-sidebar);
}
.self-indicator {
fill: var(--text-dim);
font-family: var(--mono);
font-size: 9px;
}
.self-indicator.highlight { fill: var(--warning); }
.panel {
background: var(--bg-sidebar);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
}
.panel h2 {
font-family: var(--heading);
font-size: 15px;
margin: 0 0 4px;
color: var(--text-heading);
}
.panel .desc {
color: var(--text-muted);
font-size: 12px;
margin: 0 0 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.step {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
padding: 10px 8px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.12s;
margin-bottom: 2px;
}
.step:hover { background: var(--bg-card); }
.step.active { background: var(--accent-dim); }
.step .num {
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: 50%;
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-primary);
}
.step.active .num { background: var(--accent); color: var(--bg-sidebar); border-color: var(--accent); }
.step .body { min-width: 0; }
.step .from-to {
font-family: var(--mono);
font-size: 10px;
color: var(--text-muted);
margin-bottom: 3px;
word-break: break-all;
}
.step .from-to .arrow { color: var(--accent); }
.step .label {
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
margin-bottom: 3px;
}
.step .passes {
color: var(--text-muted);
font-family: var(--mono);
font-size: 10px;
white-space: pre-wrap;
word-break: break-word;
}
.step .passes::before { content: "passes: "; color: var(--text-dim); }
.step .meta {
display: inline-flex; gap: 6px;
margin-top: 4px;
font-family: var(--mono);
font-size: 10px;
}
.step .via-tag {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 1px 6px;
border-radius: 3px;
}
.step .unverified {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
color: var(--warning);
padding: 1px 6px;
border-radius: 3px;
}
/* Legend */
.legend {
position: absolute;
top: 12px;
right: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 10px;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
}
.legend .row { display: flex; align-items: center; gap: 6px; }
.legend .dot { width: 8px; height: 8px; border-radius: 2px; }
/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg-sidebar); }
::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="app">
<header>
<h1>ResolutionFlow Workflows</h1>
<span class="badge" id="counts">loading…</span>
<span class="spacer"></span>
<span class="meta">Click a flow to trace its path. Node-per-file granularity.</span>
</header>
<aside class="sidebar" id="sidebar"></aside>
<main class="canvas-wrap" id="canvas">
<div class="placeholder" id="placeholder">
<h2>Pick a flow</h2>
<p>Each flow is an ordered trace of how a user action moves through the codebase — page → component → API client → endpoint → service → DB/external.</p>
<p>Nodes are <strong>individual files</strong>. Numbered arrows show data flow direction; click a step in the right panel to highlight it.</p>
</div>
</main>
<aside class="panel" id="panel">
<h2>Steps</h2>
<p class="desc">Select a flow on the left.</p>
</aside>
</div>
<script>
const LAYER_ORDER = [
"page", "component", "hook", "store",
"api_client", "http_client", "endpoint",
"service", "core", "model", "external"
];
const LAYER_LABEL = {
page: "Page", component: "Component", hook: "Hook", store: "Store",
api_client: "API Client", http_client: "HTTP Client", endpoint: "Endpoint",
service: "Service", core: "Core", model: "Model", external: "External"
};
const LAYER_COLOR = (l) => getComputedStyle(document.documentElement).getPropertyValue(`--layer-${l}`).trim() || "#888";
const COLUMN_WIDTH = 260;
const COLUMN_PADDING = 28;
const NODE_HEIGHT = 44;
const NODE_WIDTH = 220;
const NODE_GAP = 12;
const HEADER_HEIGHT = 36;
const CANVAS_PADDING_TOP = 24;
const CANVAS_PADDING_BOTTOM = 40;
let DATA = null;
let nodeById = {};
let activeFlowId = null;
let activeStepIndex = null;
fetch("workflows.json").then(r => r.json()).then(d => { DATA = d; init(); }).catch(err => {
document.getElementById("placeholder").innerHTML = `<h2>Couldn't load workflows.json</h2><p>${err}</p><p>Serve this directory with a static server, then open workflows.html — opening directly via file:// may be blocked by CORS.</p>`;
});
function init() {
for (const n of DATA.nodes) nodeById[n.id] = n;
document.getElementById("counts").textContent = `${DATA.nodes.length} files · ${DATA.flows.length} flows`;
renderSidebar();
}
function renderSidebar() {
const sb = document.getElementById("sidebar");
const groups = {};
for (const f of DATA.flows) (groups[f.group] = groups[f.group] || []).push(f);
const groupOrder = ["Auth & Access", "Sessions & FlowPilot", "Flow Authoring", "Integrations", "Team & Billing", "Tools"];
const ordered = groupOrder.filter(g => groups[g]).concat(Object.keys(groups).filter(g => !groupOrder.includes(g)));
let html = "";
for (const g of ordered) {
html += `<div class="group">${escapeHtml(g)}</div>`;
for (const f of groups[g]) {
html += `<button class="flow-item" data-flow="${escapeHtml(f.id)}">
<span>${escapeHtml(f.label)}</span>
<span class="count">${f.steps.length}</span>
</button>`;
}
}
sb.innerHTML = html;
sb.querySelectorAll("button.flow-item").forEach(btn => {
btn.addEventListener("click", () => selectFlow(btn.dataset.flow));
});
}
function selectFlow(flowId) {
activeFlowId = flowId;
activeStepIndex = null;
document.querySelectorAll(".sidebar .flow-item").forEach(b => b.classList.toggle("active", b.dataset.flow === flowId));
const flow = DATA.flows.find(f => f.id === flowId);
renderGraph(flow);
renderPanel(flow);
}
// Compute layout: each flow's nodes positioned in layer columns.
function layoutFlow(flow) {
// Collect distinct nodes referenced by the flow (in step order)
const seen = new Set();
const nodes = [];
for (const s of flow.steps) {
for (const ep of [s.from, s.to]) {
if (!seen.has(ep) && nodeById[ep]) { seen.add(ep); nodes.push(nodeById[ep]); }
}
}
// Group by layer, preserve first-appearance order within layer
const byLayer = {};
for (const n of nodes) (byLayer[n.layer] = byLayer[n.layer] || []).push(n);
const activeLayers = LAYER_ORDER.filter(l => byLayer[l] && byLayer[l].length);
const positions = {};
let maxRows = 0;
activeLayers.forEach((layer, colIdx) => {
const col = byLayer[layer];
col.forEach((node, rowIdx) => {
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH;
const y = CANVAS_PADDING_TOP + HEADER_HEIGHT + rowIdx * (NODE_HEIGHT + NODE_GAP);
positions[node.id] = { x, y, w: NODE_WIDTH, h: NODE_HEIGHT, node, layer, col: colIdx, row: rowIdx };
});
maxRows = Math.max(maxRows, col.length);
});
const width = COLUMN_PADDING * 2 + activeLayers.length * COLUMN_WIDTH;
const height = CANVAS_PADDING_TOP + HEADER_HEIGHT + maxRows * (NODE_HEIGHT + NODE_GAP) + CANVAS_PADDING_BOTTOM;
return { positions, activeLayers, width, height };
}
function renderGraph(flow) {
const canvas = document.getElementById("canvas");
const placeholder = document.getElementById("placeholder");
if (placeholder) placeholder.style.display = "none";
const layout = layoutFlow(flow);
const svg = createSvg(layout.width, layout.height);
// Column band backgrounds
layout.activeLayers.forEach((layer, colIdx) => {
const x = COLUMN_PADDING + colIdx * COLUMN_WIDTH - 10;
svg.appendChild(rect(x, CANVAS_PADDING_TOP - 8, NODE_WIDTH + 20, layout.height - CANVAS_PADDING_TOP, "column-band"));
const headerEl = text(x + NODE_WIDTH / 2 + 10, CANVAS_PADDING_TOP + 12, LAYER_LABEL[layer] || layer, "layer-header");
headerEl.setAttribute("text-anchor", "middle");
svg.appendChild(headerEl);
});
// Dedupe: group steps by (from, to). One curve per unique pair.
// Track separate counts of mutual pairs (A→B and B→A both exist) so we offset their curves.
const edgeGroups = new Map();
const selfSteps = new Map(); // node id → [step indexes] for from===to (state updates)
flow.steps.forEach((step, idx) => {
if (step.from === step.to) {
const arr = selfSteps.get(step.from) || [];
arr.push(idx);
selfSteps.set(step.from, arr);
return;
}
const key = step.from + ">>" + step.to;
const grp = edgeGroups.get(key) || { from: step.from, to: step.to, steps: [] };
grp.steps.push(idx);
edgeGroups.set(key, grp);
});
// Detect mutual pairs (both A→B and B→A present) so we curve them apart
const mutualPairs = new Set();
for (const [key, grp] of edgeGroups) {
const reverseKey = grp.to + ">>" + grp.from;
if (edgeGroups.has(reverseKey)) mutualPairs.add(key);
}
const edgesGroup = group("edges");
svg.appendChild(edgesGroup);
const badgesGroup = group("badges");
// (badges added later — drawn over nodes)
for (const [key, grp] of edgeGroups) {
const fromPos = layout.positions[grp.from];
const toPos = layout.positions[grp.to];
if (!fromPos || !toPos) continue;
const isMutual = mutualPairs.has(key);
drawEdgeGroup(edgesGroup, badgesGroup, fromPos, toPos, grp, isMutual, flow.steps);
}
// Nodes
const nodeGroup = group("nodes");
svg.appendChild(nodeGroup);
for (const id in layout.positions) {
const pos = layout.positions[id];
drawNode(nodeGroup, pos, selfSteps.get(id) || []);
}
// Append badges last so they sit above nodes
svg.appendChild(badgesGroup);
canvas.innerHTML = "";
canvas.appendChild(svg);
svg.addEventListener("click", (e) => {
// Click on empty SVG background clears highlight
if (e.target === svg || e.target.classList.contains("column-band")) clearStepHighlight();
});
// Legend
const legend = document.createElement("div");
legend.className = "legend";
legend.innerHTML = `
<div class="row"><strong style="color:var(--text-primary)">Layers</strong></div>
${layout.activeLayers.map(l => `<div class="row"><span class="dot" style="background:${LAYER_COLOR(l)}"></span>${LAYER_LABEL[l]||l}</div>`).join("")}
`;
canvas.appendChild(legend);
}
function drawEdgeGroup(edgeLayer, badgeLayer, from, to, grp, isMutual, allSteps) {
const forward = to.col >= from.col;
// Anchor on the side of the node that fits the direction.
let fromX, fromY, toX, toY;
if (forward) {
fromX = from.x + from.w; toX = to.x;
} else {
// Backward: exit from left of source, enter right of target
fromX = from.x; toX = to.x + to.w;
}
fromY = from.y + from.h / 2;
toY = to.y + to.h / 2;
// Offset mutual pairs perpendicularly so the two arrows don't sit on top of each other.
// Forward edges get a small upward offset; backward gets downward (or vice versa) when mutual.
let perpOffset = 0;
if (isMutual) perpOffset = forward ? -18 : 18;
// Bezier control points
const dx = toX - fromX;
const dy = toY - fromY;
let c1x, c1y, c2x, c2y;
if (forward) {
const horiz = Math.max(50, Math.abs(dx) * 0.4);
c1x = fromX + horiz;
c2x = toX - horiz;
c1y = fromY + perpOffset;
c2y = toY + perpOffset;
} else {
// Backward — arc out to the side, then back. We use a wide horizontal sweep.
const sweep = 80 + Math.abs(dy) * 0.15;
c1x = fromX - sweep;
c2x = toX + sweep;
c1y = fromY + perpOffset;
c2y = toY + perpOffset;
}
const pathD = `M ${fromX} ${fromY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${toX} ${toY}`;
const stepNums = grp.steps.map(i => i + 1).join(",");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pathD);
path.setAttribute("class", "edge");
path.setAttribute("data-steps", stepNums);
path.setAttribute("marker-end", "url(#arrow)");
edgeLayer.appendChild(path);
// Step-number badges placed along the curve at evenly spaced t-values.
const k = grp.steps.length;
const SPREAD = Math.min(0.18, 0.55 / Math.max(1, k));
grp.steps.forEach((stepIdx, i) => {
// t centered around 0.5 — for k=1, t=0.5; for k=2 t=0.41, 0.59; etc.
const t = 0.5 + (i - (k - 1) / 2) * SPREAD;
const p = cubicAt(t, [fromX, fromY], [c1x, c1y], [c2x, c2y], [toX, toY]);
const numG = document.createElementNS("http://www.w3.org/2000/svg", "g");
numG.setAttribute("data-step", stepIdx + 1);
numG.setAttribute("class", "edge-num");
numG.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", p.x);
circle.setAttribute("cy", p.y);
circle.setAttribute("r", 9);
circle.setAttribute("class", "edge-num-bg");
numG.appendChild(circle);
const numText = document.createElementNS("http://www.w3.org/2000/svg", "text");
numText.setAttribute("x", p.x);
numText.setAttribute("y", p.y);
numText.setAttribute("class", "edge-num-text");
numText.textContent = stepIdx + 1;
numG.appendChild(numText);
const step = allSteps[stepIdx];
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `${stepIdx + 1}. ${step.label}\n${step.via}\npasses: ${step.passes}`;
numG.appendChild(title);
badgeLayer.appendChild(numG);
});
}
function cubicAt(t, P0, P1, P2, P3) {
const mt = 1 - t;
const x = mt*mt*mt*P0[0] + 3*mt*mt*t*P1[0] + 3*mt*t*t*P2[0] + t*t*t*P3[0];
const y = mt*mt*mt*P0[1] + 3*mt*mt*t*P1[1] + 3*mt*t*t*P2[1] + t*t*t*P3[1];
return { x, y };
}
function drawNode(parent, pos, selfStepIdxs) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("class", "node in-flow");
g.setAttribute("transform", `translate(${pos.x}, ${pos.y})`);
const r = rect(0, 0, pos.w, pos.h);
g.appendChild(r);
// Layer-color top pill
const pill = rect(0, 0, pos.w, 4, "layer-pill");
pill.setAttribute("fill", LAYER_COLOR(pos.layer));
pill.setAttribute("rx", 6);
g.appendChild(pill);
const label = text(10, pos.h / 2 - 7, pos.node.label, "label");
g.appendChild(label);
const fp = pos.node.file;
let sub = fp;
if (sub.startsWith("external:")) sub = sub.replace("external:", "ext · ");
else if (sub.length > 32) {
const parts = sub.split("/");
sub = parts.slice(-3).join("/");
if (sub.length > 32) sub = "…/" + parts[parts.length - 1];
}
const subEl = text(10, pos.h / 2 + 8, sub, "sublabel");
g.appendChild(subEl);
// Self-loop steps (from === to, typically state_update) — render as a small "↻ N" indicator
// in the upper-right of the node, clickable to highlight that step.
if (selfStepIdxs.length) {
const xRight = pos.w - 8;
selfStepIdxs.slice(0, 3).forEach((stepIdx, i) => {
const sg = document.createElementNS("http://www.w3.org/2000/svg", "g");
sg.setAttribute("class", "edge-num self-step");
sg.setAttribute("data-step", stepIdx + 1);
sg.addEventListener("click", (e) => { e.stopPropagation(); highlightStep(stepIdx); });
const cx = xRight - i * 18;
const cy = 14;
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", cx);
circle.setAttribute("cy", cy);
circle.setAttribute("r", 8);
circle.setAttribute("class", "edge-num-bg");
sg.appendChild(circle);
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", cx);
t.setAttribute("y", cy);
t.setAttribute("class", "edge-num-text");
t.textContent = stepIdx + 1;
sg.appendChild(t);
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `Step ${stepIdx + 1} (self · stays on this node)`;
sg.appendChild(title);
g.appendChild(sg);
});
}
// Hover tooltip
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = `${pos.node.label}\n${pos.node.file}\n${pos.node.description || ""}`;
g.appendChild(title);
parent.appendChild(g);
}
function renderPanel(flow) {
const p = document.getElementById("panel");
let html = `<h2>${escapeHtml(flow.label)}</h2>`;
if (flow.description) html += `<p class="desc">${escapeHtml(flow.description)}</p>`;
flow.steps.forEach((step, idx) => {
const fromNode = nodeById[step.from], toNode = nodeById[step.to];
const fromLabel = fromNode ? fromNode.label : step.from;
const toLabel = toNode ? toNode.label : step.to;
html += `<div class="step" data-step="${idx}">
<div class="num">${idx + 1}</div>
<div class="body">
<div class="from-to">${escapeHtml(fromLabel)} <span class="arrow">→</span> ${escapeHtml(toLabel)}</div>
<div class="label">${escapeHtml(step.label || "(unlabeled)")}</div>
${step.passes ? `<div class="passes">${escapeHtml(step.passes)}</div>` : ""}
<div class="meta">
<span class="via-tag">${escapeHtml(step.via)}</span>
${step.unverified ? `<span class="unverified">unverified</span>` : ""}
</div>
</div>
</div>`;
});
p.innerHTML = html;
p.querySelectorAll(".step").forEach(el => {
el.addEventListener("click", () => highlightStep(+el.dataset.step));
});
p.scrollTop = 0;
}
function highlightStep(idx) {
activeStepIndex = idx;
const stepNum = idx + 1;
document.querySelectorAll(".panel .step").forEach((el, i) => el.classList.toggle("active", i === idx));
// Edges: a path can carry multiple steps (comma-separated). Highlight if this stepNum is in its list.
document.querySelectorAll("svg.graph .edge").forEach(e => {
const nums = (e.getAttribute("data-steps") || "").split(",").map(Number);
const isOn = nums.includes(stepNum);
e.classList.toggle("highlight", isOn);
e.classList.toggle("has-active", !isOn);
});
// Step-number badges: highlight the one matching this step; others stay visible but dimmed.
document.querySelectorAll("svg.graph .edge-num").forEach(g => {
const n = +g.getAttribute("data-step");
g.classList.toggle("highlight", n === stepNum);
g.classList.toggle("dim", n !== stepNum);
});
const active = document.querySelector(".panel .step.active");
if (active && typeof active.scrollIntoView === "function") {
active.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
function clearStepHighlight() {
activeStepIndex = null;
document.querySelectorAll(".panel .step.active").forEach(el => el.classList.remove("active"));
document.querySelectorAll("svg.graph .edge").forEach(e => { e.classList.remove("highlight", "has-active"); });
document.querySelectorAll("svg.graph .edge-num").forEach(g => { g.classList.remove("highlight", "dim"); });
}
/* ---------- SVG helpers ---------- */
function createSvg(w, h) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "graph");
svg.setAttribute("width", w);
svg.setAttribute("height", h);
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
// Arrow marker
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
defs.innerHTML = `
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
</marker>`;
svg.appendChild(defs);
return svg;
}
function rect(x, y, w, h, cls) {
const r = document.createElementNS("http://www.w3.org/2000/svg", "rect");
r.setAttribute("x", x); r.setAttribute("y", y);
r.setAttribute("width", w); r.setAttribute("height", h);
if (cls) r.setAttribute("class", cls);
return r;
}
function text(x, y, str, cls) {
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", x); t.setAttribute("y", y);
if (cls) t.setAttribute("class", cls);
t.textContent = str;
return t;
}
function group(cls) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
if (cls) g.setAttribute("class", cls);
return g;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[c]));
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff