diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0803c006..3436ee4a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,11 +6,11 @@ "url": "https://github.com/Shakes-tzd" }, "description": "HTML-based agent observability + workflow analytics. Git-first JSONL event tracking with rebuildable SQLite index. Zero external dependencies, works offline, version control friendly.", - "version": "0.33.77", + "version": "0.33.79", "plugins": [ { "name": "htmlgraph", - "version": "0.33.77", + "version": "0.33.79", "source": "./packages/claude-plugin", "description": "HTML-based agent observability + workflow analytics (Git-first JSONL + rebuildable SQLite index)", "category": "Development Tools", diff --git a/.claude/rules/code-hygiene.md b/.claude/rules/code-hygiene.md index 2ad21e09..ee638a57 100644 --- a/.claude/rules/code-hygiene.md +++ b/.claude/rules/code-hygiene.md @@ -45,3 +45,47 @@ git commit -m "..." ``` **Remember: Fixing errors immediately is faster than letting them accumulate.** + +## Module Size & Complexity Standards + +### Line Count Limits + +| Metric | Target | Warning | Fail (new code) | +|--------|--------|---------|------------------| +| Module | 200-500 lines | >300 lines | >500 lines | +| Function | 10-20 lines | >30 lines | >50 lines | +| Class | 100-200 lines | >200 lines | >300 lines | + +### Principles + +1. **Single Responsibility**: Each module should have one clear purpose describable in one sentence +2. **No Duplication**: Check `src/python/htmlgraph/utils/` for shared utilities before writing new ones +3. **Prefer Existing Dependencies**: Check `pyproject.toml` and stdlib before custom implementations +4. **Import Direction**: Dependencies flow one way (services -> models, never models -> services) + +### Enforcement + +- **Script**: `python scripts/check-module-size.py` checks all modules against limits +- **Pre-commit**: Runs automatically on changed files +- **Grandfathered modules**: 15 existing modules >1000 lines are tracked but not blocking (see `scripts/check-module-size.py` for list) +- **Ratchet rule**: Any modification to a grandfathered module must not increase its line count +- **Refactoring track**: See `docs/tracks/MODULE_REFACTORING_TRACK.md` for planned splits + +### Quick Commands + +```bash +# Check all modules +python scripts/check-module-size.py + +# Check only changed files +python scripts/check-module-size.py --changed-only + +# Summary table of oversized modules +python scripts/check-module-size.py --summary + +# JSON output for CI +python scripts/check-module-size.py --json + +# Strict mode (warnings = failures) +python scripts/check-module-size.py --fail-on-warning +``` diff --git a/.htmlgraph/features/feat-335cb001.html b/.htmlgraph/features/feat-335cb001.html new file mode 100644 index 00000000..62a0d602 --- /dev/null +++ b/.htmlgraph/features/feat-335cb001.html @@ -0,0 +1,36 @@ + + + + + + + Test Feature 1 + + + +
+ +
+

Test Feature 1

+ +
+ + +
+ + diff --git a/.htmlgraph/features/feat-6ce2908f.html b/.htmlgraph/features/feat-6ce2908f.html new file mode 100644 index 00000000..9a647260 --- /dev/null +++ b/.htmlgraph/features/feat-6ce2908f.html @@ -0,0 +1,51 @@ + + + + + + + Sync status monitoring + + + +
+ +
+

Sync status monitoring

+ +
+ + +
+

Implementation Steps

+
    +
  1. ⏳ Add sync status polling to EventPoller (every 3s, dedup via signature)
  2. +
  3. ⏳ Add sync_status PubSub broadcast
  4. +
  5. ⏳ Add sync_status handle_info in LiveView
  6. +
  7. ⏳ Add sync_status assign to mount
  8. +
  9. ⏳ Add sync status bar template
  10. +
  11. ⏳ Add sync status bar CSS with warn/error states
  12. +
+
+
+

Description

+

Display oplog sequence, pending conflicts, consumer count, and pipeline lag in a status bar

+
+
+ + diff --git a/.htmlgraph/features/feat-8110d25a.html b/.htmlgraph/features/feat-8110d25a.html new file mode 100644 index 00000000..c12a71f9 --- /dev/null +++ b/.htmlgraph/features/feat-8110d25a.html @@ -0,0 +1,48 @@ + + + + + + + Work item type-specific colors + + + +
+ +
+

Work item type-specific colors

+ +
+ + +
+

Implementation Steps

+
    +
  1. ⏳ Add work_item_type_class/1 helper for 8 types
  2. +
  3. ⏳ Add 8 CSS classes for work item types
  4. +
  5. ⏳ Update parent row work item badge to use typed class
  6. +
+
+
+

Description

+

Color-code work item badges by type: feature/bug/spike/task/chore/epic/track/insight

+
+
+ + diff --git a/.htmlgraph/features/feat-ce3eb030.html b/.htmlgraph/features/feat-ce3eb030.html new file mode 100644 index 00000000..a9254308 --- /dev/null +++ b/.htmlgraph/features/feat-ce3eb030.html @@ -0,0 +1,48 @@ + + + + + + + Model name formatting + + + +
+ +
+

Model name formatting

+ +
+ + +
+

Implementation Steps

+
    +
  1. ⏳ Add format_model_name/1 helper function
  2. +
  3. ⏳ Update model badge in event_row to use formatter
  4. +
  5. ⏳ Update model badges in parent row stats
  6. +
+
+
+

Description

+

Strip claude- prefix and reformat version numbers (claude-3-5-sonnet -> 3-5.sonnet)

+
+
+ + diff --git a/.htmlgraph/features/feat-cf7dea09.html b/.htmlgraph/features/feat-cf7dea09.html new file mode 100644 index 00000000..de5422be --- /dev/null +++ b/.htmlgraph/features/feat-cf7dea09.html @@ -0,0 +1,36 @@ + + + + + + + Test Feature 2 + + + +
+ +
+

Test Feature 2

+ +
+ + +
+ + diff --git a/.htmlgraph/features/feat-e3ebc1c7.html b/.htmlgraph/features/feat-e3ebc1c7.html new file mode 100644 index 00000000..e9ada972 --- /dev/null +++ b/.htmlgraph/features/feat-e3ebc1c7.html @@ -0,0 +1,51 @@ + + + + + + + Agent filter with debounce + + + +
+ +
+

Agent filter with debounce

+ +
+ + +
+

Implementation Steps

+
    +
  1. ⏳ Add agent_filter assign to LiveView mount
  2. +
  3. ⏳ Add filter_agent handle_event
  4. +
  5. ⏳ Add agent_id param to Activity.list_activity_feed/1
  6. +
  7. ⏳ Add fetch_user_queries/3 with EXISTS subquery for agent filtering
  8. +
  9. ⏳ Add filter input to template with phx-debounce=500
  10. +
  11. ⏳ Add filter bar CSS
  12. +
+
+
+

Description

+

Add text input to filter activity feed by agent name with 500ms debounce

+
+
+ + diff --git a/.htmlgraph/features/feat-e6e1c8a2.html b/.htmlgraph/features/feat-e6e1c8a2.html new file mode 100644 index 00000000..c8a5d290 --- /dev/null +++ b/.htmlgraph/features/feat-e6e1c8a2.html @@ -0,0 +1,48 @@ + + + + + + + Subagent badge classification + + + +
+ +
+

Subagent badge classification

+ +
+ + +
+

Implementation Steps

+
    +
  1. ⏳ Add subagent_badge_class/1 helper function
  2. +
  3. ⏳ Add 5 CSS badge classes (researcher, haiku, opus, green, claude)
  4. +
  5. ⏳ Update event_row template to use typed badges
  6. +
+
+
+

Description

+

Port 5-family subagent badge system from HTMX (researcher/haiku/opus/test+debug/claude)

+
+
+ + diff --git a/.htmlgraph/htmlgraph.db b/.htmlgraph/htmlgraph.db index 8a7a4dbf..becf0fff 100644 Binary files a/.htmlgraph/htmlgraph.db and b/.htmlgraph/htmlgraph.db differ diff --git a/.htmlgraph/sessions/abfaeabd-c6e6-4109-a575-06e0b9548eb7.html b/.htmlgraph/sessions/abfaeabd-c6e6-4109-a575-06e0b9548eb7.html index 6a9c3b44..0b0a7241 100644 --- a/.htmlgraph/sessions/abfaeabd-c6e6-4109-a575-06e0b9548eb7.html +++ b/.htmlgraph/sessions/abfaeabd-c6e6-4109-a575-06e0b9548eb7.html @@ -13,15 +13,15 @@ data-status="active" data-agent="claude-code" data-started-at="2026-03-14T04:13:55.379271" - data-last-activity="2026-03-14T08:19:22.927048+00:00" - data-event-count="10" data-is-subagent="false" data-start-commit="516c5ff9"> + data-last-activity="2026-03-15T14:51:31.997103+00:00" + data-event-count="39" data-is-subagent="false" data-start-commit="516c5ff9">

Session 2026-03-14 04:13

Active claude-code - 10 events + 39 events
@@ -35,8 +35,37 @@

Worked On:

-

Activity Log (10 events)

+

Activity Log (39 events)

    +
  1. "lets push this and merge the work to main"
  2. +
  3. Agent stopped
  4. +
  5. Create SDK integration track
  6. +
  7. "create a track for it"
  8. +
  9. Agent stopped
  10. +
  11. "that means runing the phonix server and running fast api why not execute python from elixir"
  12. +
  13. Agent stopped
  14. +
  15. Check deploy output
  16. +
  17. Verify PyPI version
  18. +
  19. Agent stopped
  20. +
  21. "so its not using the html graph database?"
  22. +
  23. Agent stopped
  24. +
  25. Deploy 0.33.79 to PyPI
  26. +
  27. "yes please deploy and no need to run tests"
  28. +
  29. Agent stopped
  30. +
  31. Check PyPI version
  32. +
  33. "is the graph attribution system active now?"
  34. +
  35. Agent stopped
  36. +
  37. "it seems like most of our work was not being tracked"
  38. +
  39. Agent stopped
  40. +
  41. Start Phoenix dashboard server
  42. +
  43. "ok can you run the phoenix application"
  44. +
  45. Agent stopped
  46. +
  47. Push restored commits
  48. +
  49. Commit restored files
  50. +
  51. Restore remaining files (skip untracked Phoenix files)
  52. +
  53. Restore specific files from old remote branch
  54. +
  55. Abort stale cherry-pick and retry
  56. +
  57. Stash and cherry-pick overwritten commits
  58. "continue"
  59. Agent stopped
  60. Verify commit succeeded
  61. diff --git a/.htmlgraph/sessions/sess-085fc425.html b/.htmlgraph/sessions/sess-085fc425.html new file mode 100644 index 00000000..ed487222 --- /dev/null +++ b/.htmlgraph/sessions/sess-085fc425.html @@ -0,0 +1,36 @@ + + + + + + + Session 2026-03-15 07:56 + + + +
    + +
    +

    Session 2026-03-15 07:56

    + +
    + +
    +

    Activity Log (1 events)

    +
      +
    1. Session started
    2. +
    +
    +
    + + diff --git a/.htmlgraph/sessions/sess-9d208b58.html b/.htmlgraph/sessions/sess-9d208b58.html new file mode 100644 index 00000000..60ba71eb --- /dev/null +++ b/.htmlgraph/sessions/sess-9d208b58.html @@ -0,0 +1,36 @@ + + + + + + + Session 2026-03-15 07:56 + + + +
    + +
    +

    Session 2026-03-15 07:56

    + +
    + +
    +

    Activity Log (1 events)

    +
      +
    1. Session started
    2. +
    +
    +
    + + diff --git a/.htmlgraph/sessions/sess-b55e1899.html b/.htmlgraph/sessions/sess-b55e1899.html index 4bce7aee..75c90437 100644 --- a/.htmlgraph/sessions/sess-b55e1899.html +++ b/.htmlgraph/sessions/sess-b55e1899.html @@ -13,15 +13,15 @@ data-status="active" data-agent="codex" data-started-at="2026-02-16T21:12:08.045229" - data-last-activity="2026-03-14T05:04:09.663916+00:00" - data-event-count="119" data-is-subagent="false" data-start-commit="ee21fc61"> + data-last-activity="2026-03-15T11:46:55.150914+00:00" + data-event-count="120" data-is-subagent="false" data-start-commit="ee21fc61">

    MCP 2026-02-16 21:12

    @@ -203,8 +203,9 @@

    Detected Patterns (24)

-

Activity Log (119 events)

+

Activity Log (120 events)

    +
  1. MCP get_active_feature
  2. Ran requested stdout echo command
  3. MCP get_active_feature
  4. Reviewed commit 516c5ff version bump for consistency across package/plugin metadata; no issues found after running version verifier and checking for stale references.
  5. diff --git a/.htmlgraph/sessions/sess-e1f8958b.html b/.htmlgraph/sessions/sess-e1f8958b.html index 3eec00b6..60f24ce1 100644 --- a/.htmlgraph/sessions/sess-e1f8958b.html +++ b/.htmlgraph/sessions/sess-e1f8958b.html @@ -13,15 +13,15 @@ data-status="active" data-agent="test-agent" data-started-at="2026-01-10T02:51:19.332692" - data-last-activity="2026-03-14T05:04:16.330469+00:00" - data-event-count="319" data-is-subagent="false" data-start-commit="285c11e"> + data-last-activity="2026-03-15T12:02:02.069805+00:00" + data-event-count="321" data-is-subagent="false" data-start-commit="285c11e">

    E2E Test

    @@ -348,6 +348,8 @@

    Worked On:

  6. feat-1dd42e8c
  7. feat-7b504c04
  8. feat-6c4ab424
  9. +
  10. feat-335cb001
  11. +
  12. feat-cf7dea09
@@ -373,8 +375,10 @@

Detected Patterns (1)

-

Activity Log (319 events)

+

Activity Log (321 events)

    +
  1. Created: features/feat-cf7dea09
  2. +
  3. Created: features/feat-335cb001
  4. Created: features/feat-6c4ab424
  5. Created: features/feat-7b504c04
  6. Created: features/feat-1dd42e8c
  7. diff --git a/.htmlgraph/spikes/spk-391d1622.html b/.htmlgraph/spikes/spk-391d1622.html new file mode 100644 index 00000000..21ff15df --- /dev/null +++ b/.htmlgraph/spikes/spk-391d1622.html @@ -0,0 +1,67 @@ + + + + + + + Results: task-6fcf4bd8 - Count documentation files + + + +
    + +
    +

    Results: task-6fcf4bd8 - Count documentation files

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Count documentation files +# Task ID: task-6fcf4bd8 +# Status: completed + +## Results + +## Task Completed Successfully + +### Summary +Counted documentation files in docs/ directory. + +### Results +- **Total files**: 17 markdown files +- **Documentation coverage**: Good (API, guides, examples) + +### Status +Success - Read-only operation completed + +## Linked Work Items +None + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/spikes/spk-3c3b2e8f.html b/.htmlgraph/spikes/spk-3c3b2e8f.html new file mode 100644 index 00000000..dc10cdfb --- /dev/null +++ b/.htmlgraph/spikes/spk-3c3b2e8f.html @@ -0,0 +1,81 @@ + + + + + + + Results: task-71bce1ec - Review architecture docs + + + +
    + +
    +

    Results: task-71bce1ec - Review architecture docs

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Review architecture docs +# Task ID: task-71bce1ec +# Status: completed + +## Results + +## Architecture Review Complete + +### Notebook Architecture + +**Document**: /Users/shakes/DevProjects/causal-compass/NOTEBOOK_ARCHITECTURE.md + +**Key Principles**: +1. Notebook-centric design +2. Progressive disclosure +3. Interactive exploration over passive reading +4. Real-time validation + +**Interactive Analysis Guidelines**: +- Should build on previous notebook cells +- Provide immediate feedback +- Guide user discovery +- Support experimentation + +**Component Patterns**: +- Cell-based components +- Interactive widgets +- Data visualization +- State management via notebook kernel + +**Alignment**: New Interactive Analysis step should follow these patterns + +## Linked Work Items +- Feature: feat-279f6f50 + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/spikes/spk-5e7d5ae0.html b/.htmlgraph/spikes/spk-5e7d5ae0.html new file mode 100644 index 00000000..054c90bd --- /dev/null +++ b/.htmlgraph/spikes/spk-5e7d5ae0.html @@ -0,0 +1,78 @@ + + + + + + + Results: task-6662eb47 - Analyze lesson structure + + + +
    + +
    +

    Results: task-6662eb47 - Analyze lesson structure

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Analyze lesson structure +# Task ID: task-6662eb47 +# Status: completed + +## Results + +## Investigation Complete + +### Current Lesson Structure + +**Location**: /Users/shakes/DevProjects/causal-compass/src/lessons/ + +**Findings**: +- Lessons organized by notebook type +- Each lesson has: setup, content, interactive steps +- Interactive Analysis step is placeholder in most lessons +- Structure follows notebook-first pattern + +**Key Files**: +- `src/lessons/BasicLesson.tsx` - Base template +- `src/lessons/CausalLesson.tsx` - Main causal analysis +- Needs: Structured Interactive Analysis component + +**Recommendation**: Create InteractiveAnalysisStep component with: +- Data exploration UI +- Variable selection +- Real-time feedback +- Step-by-step guidance + +## Linked Work Items +- Feature: feat-279f6f50 + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/spikes/spk-7bc70e19.html b/.htmlgraph/spikes/spk-7bc70e19.html new file mode 100644 index 00000000..8b0c8456 --- /dev/null +++ b/.htmlgraph/spikes/spk-7bc70e19.html @@ -0,0 +1,67 @@ + + + + + + + Results: task-36df7730 - Count Python files in src/ + + + +
    + +
    +

    Results: task-36df7730 - Count Python files in src/

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Count Python files in src/ +# Task ID: task-36df7730 +# Status: completed + +## Results + +## Task Completed Successfully + +### Summary +Counted Python files in src/python/htmlgraph/ directory. + +### Results +- **Total files**: 87 Python files +- **Subdirectories**: 7 (analytics, builders, collections, hooks, scripts, services, root) + +### Status +Success - Read-only operation completed + +## Linked Work Items +None + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/spikes/spk-9634df34.html b/.htmlgraph/spikes/spk-9634df34.html new file mode 100644 index 00000000..8e36003d --- /dev/null +++ b/.htmlgraph/spikes/spk-9634df34.html @@ -0,0 +1,103 @@ + + + + + + + Results: consolidation-task-666 - Consolidated analysis for feat-279f6f50 + + + +
    + +
    +

    Results: consolidation-task-666 - Consolidated analysis for feat-279f6f50

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Consolidated analysis for feat-279f6f50 +# Task ID: consolidation-task-666 +# Status: completed + +## Results + + +## Consolidated Analysis for feat-279f6f50 + +### Investigation Summary +Completed 3 parallel investigation tasks: +1. Current lesson structure analysis +2. Architecture documentation review +3. Existing component inventory + +### Key Findings + +**Current State**: +- Lessons follow notebook-first pattern +- Interactive Analysis step is placeholder +- Rich component library exists for reuse + +**Architecture Alignment**: +- Must follow progressive disclosure principle +- Should build on notebook cells +- Requires real-time validation + +**Available Components**: +- DataExplorer, VariableSelector, ChartWidget (reusable) +- Missing: InteractiveAnalysisWizard, StepProgress, AnalysisPreview + +### Recommended Implementation + +**Phase 1**: Create 3 new components +- InteractiveAnalysisWizard - Main orchestration +- StepProgress - User guidance +- AnalysisPreview - Result preview + +**Phase 2**: Integrate with lessons +- Update BasicLesson.tsx +- Update CausalLesson.tsx +- Wire up to notebook kernel + +**Phase 3**: Testing & refinement +- User testing with sample analyses +- Iterate on UX +- Performance optimization + +### Work Item Tracking +- Feature: feat-279f6f50 +- Investigation spikes: spk-5e7d5ae0, spk-3c3b2e8f, spk-dd4326e1 +- Status: Ready for implementation + + +## Linked Work Items +- Feature: feat-279f6f50 + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/spikes/spk-a799dde7.html b/.htmlgraph/spikes/spk-a799dde7.html new file mode 100644 index 00000000..8aacd6e3 --- /dev/null +++ b/.htmlgraph/spikes/spk-a799dde7.html @@ -0,0 +1,67 @@ + + + + + + + Results: task-340b03c1 - Count test files + + + +
    + +
    +

    Results: task-340b03c1 - Count test files

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Count test files +# Task ID: task-340b03c1 +# Status: completed + +## Results + +## Task Completed Successfully + +### Summary +Counted test files in tests/ directory. + +### Results +- **Total files**: 56 test files +- **Subdirectories**: tests/python (47 files), tests/integration (3 files), tests/benchmarks (3 files) + +### Status +Success - Read-only operation completed + +## Linked Work Items +None + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/spikes/spk-dd4326e1.html b/.htmlgraph/spikes/spk-dd4326e1.html new file mode 100644 index 00000000..579f5881 --- /dev/null +++ b/.htmlgraph/spikes/spk-dd4326e1.html @@ -0,0 +1,81 @@ + + + + + + + Results: task-7f0c2e28 - Check UI components + + + +
    + +
    +

    Results: task-7f0c2e28 - Check UI components

    + +
    + + +
    +

    Spike Metadata

    +
    +
    Type
    +
    General
    +
    Timebox
    +
    4 hours
    +
    +
    +
    +

    Findings

    +
    + # Task: Check UI components +# Task ID: task-7f0c2e28 +# Status: completed + +## Results + +## Component Inventory Complete + +### Existing UI Components + +**Location**: /Users/shakes/DevProjects/causal-compass/src/components/ + +**Found Components**: +- `NotebookCell.tsx` - Base cell component +- `DataExplorer.tsx` - Data table viewer +- `VariableSelector.tsx` - Variable picker +- `ChartWidget.tsx` - Visualization component +- `FeedbackPanel.tsx` - User guidance + +**Reusable for Interactive Analysis**: +✅ DataExplorer - Can show intermediate results +✅ VariableSelector - For choosing analysis variables +✅ ChartWidget - For visual exploration +✅ FeedbackPanel - For step hints + +**Missing Components**: +- InteractiveAnalysisWizard +- StepProgress tracker +- AnalysisPreview + +**Recommendation**: Compose existing + create 3 new components + +## Linked Work Items +- Feature: feat-279f6f50 + +## Metadata +- Saved by: orchestrator +- Task pattern: delegate_with_id +
    +
    +
    + + diff --git a/.htmlgraph/tracks/trk-92f36021.html b/.htmlgraph/tracks/trk-92f36021.html new file mode 100644 index 00000000..2fad8147 --- /dev/null +++ b/.htmlgraph/tracks/trk-92f36021.html @@ -0,0 +1,462 @@ + + + + + + + Track: Phoenix Dashboard SDK Integration + + + + + + +
    +
    +

    Phoenix Dashboard SDK Integration

    + +
    + +
    +

    Replace direct SQLite queries in Phoenix LiveView with HtmlGraph Python SDK calls via Elixir Port, enabling full graph traversal, step attribution, and edge queries from a single server.

    +
    + +
    +

    Overview

    +

    Wire Phoenix LiveView dashboard to use HtmlGraph Python SDK instead of raw SQLite queries. Use Elixir Port with a stdio JSON protocol to keep a warm Python process for low-latency SDK calls.

    +
    +
    +

    Context

    +

    The Phoenix dashboard currently duplicates query logic in Elixir that already exists in the Python SDK. It misses graph edges, step attribution, and ready-step resolution. Running two servers (FastAPI + Phoenix) is unnecessary.

    +
    +
    +

    Requirements

    +
    + +
    +

    ⏳ Stdio JSON protocol Python bridge (stdin/stdout, JSON-line delimited)

    + must-have +
    +
    +

    ⏳ Elixir Port GenServer wrapping the Python process with health checks

    + must-have +
    +
    +

    ⏳ Replace Activity.list_activity_feed with SDK call via bridge

    + must-have +
    +
    +

    ⏳ Replace fetch_work_item_detail with SDK call via bridge

    + must-have +
    +
    +

    ⏳ Replace fetch_work_item_titles with SDK call via bridge

    + must-have +
    +
    +

    ⏳ Graph edge queries via SDK (relates_to, blocks, spawned_from)

    + must-have +
    +
    +

    ⏳ Step attribution display (active step, ready steps, completion %)

    + must-have +
    +
    +

    ⏳ Warm process with <10ms query latency

    + should-have +
    +
    +

    ⏳ Graceful restart on Python process crash

    + should-have +
    +
    +

    ⏳ Remove all raw SQLite queries from Elixir code

    + nice-to-have +
    +
    +
    +
    +

    Acceptance Criteria

    +
      +
    1. ⏳ Phoenix dashboard renders identical UI using SDK bridge visual comparison test
    2. ⏳ Graph edges visible in work item detail panel expand feature, check edges section
    3. ⏳ Step attribution badges on events (step-feat-xxx-N) check event rows for step badges
    4. ⏳ Python process stays warm across page loads (<10ms response) measure latency in logs
    5. ⏳ Dashboard recovers gracefully if Python process crashes kill Python, verify auto-restart
    6. +
    +
    +
    +

    Implementation Plan

    +
    +
    + 0% Complete + (0/16 tasks) +
    +
    +
    +
    +
    +
    + +
    + Phase 1: Phase 1: Python stdio bridge (0/3 tasks) + +
    + +
    + ○ Create src/python/htmlgraph/bridge.py — JSON-line protocol server +
    + (2.0h) +
    +
    + +
    + ○ Commands: list_activity_feed, get_work_item, get_edges, get_step_attribution +
    + (2.0h) +
    +
    + +
    + ○ Add tests for bridge protocol +
    + (1.0h) +
    +
    +
    + Phase 2: Phase 2: Elixir Port GenServer (0/4 tasks) + +
    + +
    + ○ Create PythonBridge GenServer wrapping Port with health checks +
    + (2.0h) +
    +
    + +
    + ○ JSON encode/decode with Jason +
    + (0.5h) +
    +
    + +
    + ○ Timeout handling and auto-restart on crash +
    + (1.0h) +
    +
    + +
    + ○ Add to application supervision tree +
    + (0.5h) +
    +
    +
    + Phase 3: Phase 3: Replace Elixir queries with bridge calls (0/5 tasks) + +
    + +
    + ○ Replace Activity.list_activity_feed with bridge call +
    + (2.0h) +
    +
    + +
    + ○ Replace fetch_work_item_detail with bridge call +
    + (1.0h) +
    +
    + +
    + ○ Replace fetch_work_item_titles with bridge call +
    + (1.0h) +
    +
    + +
    + ○ Add graph edge queries to detail panel +
    + (1.5h) +
    +
    + +
    + ○ Add step attribution badges to event rows +
    + (1.5h) +
    +
    +
    + Phase 4: Phase 4: Cleanup and optimization (0/4 tasks) + +
    + +
    + ○ Remove raw SQLite queries from activity.ex +
    + (1.0h) +
    +
    + +
    + ○ Remove repo.ex if fully replaced +
    + (0.5h) +
    +
    + +
    + ○ Performance tuning — measure and optimize bridge latency +
    + (1.0h) +
    +
    + +
    + ○ End-to-end testing with Playwright +
    + (1.0h) +
    +
    +
    +
    +
    + + \ No newline at end of file diff --git a/.htmlgraph/tracks/trk-a8de6021.html b/.htmlgraph/tracks/trk-a8de6021.html new file mode 100644 index 00000000..a21b1ee4 --- /dev/null +++ b/.htmlgraph/tracks/trk-a8de6021.html @@ -0,0 +1,456 @@ + + + + + + + Track: Port HTMX Dashboard Features to Phoenix LiveView + + + + + + +
    +
    +

    Port HTMX Dashboard Features to Phoenix LiveView

    + +
    + +
    +

    Port all application-level features from the HTMX/Jinja2 dashboard to the Phoenix LiveView dashboard, achieving feature parity

    +
    + +
    +

    Overview

    +

    The HTMX dashboard has 6 features that are not HTMX-specific but were implemented there first. These are pure application logic that should be ported to Phoenix LiveView to achieve feature parity.

    +
    +
    +

    Context

    +

    Comparison analysis showed LiveView is architecturally superior (recursive components, server-side state, WebSocket diffs) but the HTMX version had a head start on feature polish. All HTMX advantages are application logic, not framework capabilities.

    +
    +
    +

    Requirements

    +
    + +
    +

    ⏳ Subagent badge classification (researcher/haiku/opus/test/claude families)

    + must-have +
    +
    +

    ⏳ Model name formatting (claude-3-5-sonnet -> 3-5.sonnet)

    + must-have +
    +
    +

    ⏳ Agent filter input with debounce

    + must-have +
    +
    +

    ⏳ Work item type-specific colors (feature/bug/spike/task/chore/epic/track/insight)

    + must-have +
    +
    +

    ⏳ Sync status monitoring (oplog, conflicts, consumer lag)

    + should-have +
    +
    +

    ⏳ Table layout CSS parity

    + nice-to-have +
    +
    +
    +
    +

    Acceptance Criteria

    +
      +
    1. ⏳ All 5 subagent families render with distinct colors visual inspection
    2. ⏳ Model names are shortened consistently unit test format_model_name/1
    3. ⏳ Agent filter debounces at 500ms and filters turns manual test
    4. ⏳ All 8 work item types have distinct badge colors visual inspection
    5. ⏳ Sync status bar shows oplog/conflicts/lag metrics manual test with active sync
    6. +
    +
    +
    +

    Implementation Plan

    +
    +
    + 0% Complete + (0/18 tasks) +
    +
    +
    +
    +
    +
    + +
    + Phase 1: Stream A: Helpers (parallel) (0/3 tasks) + +
    + +
    + ○ Add subagent_badge_class/1 helper with 5 family classifications +
    + +
    +
    + +
    + ○ Add format_model_name/1 helper with claude- prefix stripping +
    + +
    +
    + +
    + ○ Add work_item_type_class/1 helper for 8 work item types +
    + +
    +
    +
    + Phase 2: Stream B: Infrastructure (parallel) (0/6 tasks) + +
    + +
    + ○ Add agent_id filter param to Activity.list_activity_feed/1 +
    + +
    +
    + +
    + ○ Add fetch_user_queries/3 with agent_id filtering via EXISTS subquery +
    + +
    +
    + +
    + ○ Add filter_agent handle_event in LiveView +
    + +
    +
    + +
    + ○ Add sync status polling to EventPoller GenServer (every 3s) +
    + +
    +
    + +
    + ○ Add sync_status PubSub broadcast with dedup via signature comparison +
    + +
    +
    + +
    + ○ Add sync_status handle_info in LiveView +
    + +
    +
    +
    + Phase 3: Stream C: Presentation (parallel) (0/9 tasks) + +
    + +
    + ○ Add 5 subagent family badge CSS classes +
    + +
    +
    + +
    + ○ Add 8 work item type badge CSS classes +
    + +
    +
    + +
    + ○ Add filter bar CSS (input, label, container) +
    + +
    +
    + +
    + ○ Add sync status bar CSS (metrics, labels, warn/error states) +
    + +
    +
    + +
    + ○ Update event_row template to use subagent_badge_class/1 +
    + +
    +
    + +
    + ○ Update event_row template to use format_model_name/1 +
    + +
    +
    + +
    + ○ Update parent row template to use work_item_type_class/1 +
    + +
    +
    + +
    + ○ Add filter input to render/1 with phx-debounce=500 +
    + +
    +
    + +
    + ○ Add sync status bar to render/1 +
    + +
    +
    +
    +
    +
    + + \ No newline at end of file diff --git a/.htmlgraph/tracks/trk-f0515efe.html b/.htmlgraph/tracks/trk-f0515efe.html new file mode 100644 index 00000000..9517be14 --- /dev/null +++ b/.htmlgraph/tracks/trk-f0515efe.html @@ -0,0 +1,259 @@ + + + + + + + Track: Batch Operation Test Track + + + + + + +
    +
    +

    Batch Operation Test Track

    + +
    + +
    +

    +
    + +
    + + \ No newline at end of file diff --git a/docs/tracks/MODULE_REFACTORING_TRACK.md b/docs/tracks/MODULE_REFACTORING_TRACK.md new file mode 100644 index 00000000..ca9cbb75 --- /dev/null +++ b/docs/tracks/MODULE_REFACTORING_TRACK.md @@ -0,0 +1,440 @@ +# Track: Module Refactoring & Code Standards Enforcement + +**Track ID**: module-refactoring-2026Q1 +**Created**: 2026-03-15 +**Status**: Planning +**Priority**: High +**Estimated Phases**: 5 parallel execution lanes + +--- + +## Executive Summary + +HtmlGraph has 132,291 lines across 308 Python files. **15 modules exceed 1,000 lines** (the top 3 exceed 2,000 lines), violating industry standards of 300-500 lines per module. Additionally, **5 utility functions are duplicated 2-3x** across the codebase, and several custom implementations can be replaced by existing dependencies or standard library features. + +This track addresses three goals: +1. **Refactor oversized modules** into focused, single-responsibility files +2. **Eliminate code duplication** by consolidating shared utilities +3. **Enforce standards going forward** via tooling, agent instructions, and pre-commit hooks + +--- + +## Industry Standards Reference + +| Metric | Standard | Current State | +|--------|----------|---------------| +| Module size | 200-500 lines | 15 modules >1,000 lines | +| Function length | 10-20 lines (max 50) | Not yet measured | +| Class length | 100-200 lines | Not yet measured | +| Cyclomatic complexity | <10 per function | Not yet measured | +| Responsibilities per module | 1 (SRP) | Many modules have 5-10+ | + +--- + +## Phase 1: Shared Utilities Consolidation (Parallel Lane A) + +**Goal**: Eliminate duplicated code by creating canonical shared modules. + +### 1A. Formatting Utilities → `src/python/htmlgraph/utils/formatting.py` + +**Problem**: `format_number`, `format_duration`, `format_bytes`, `truncate_text`, `format_timestamp` are implemented **3 times identically** in: +- `api/templates.py` (lines 44-184) +- `api/filters.py` (lines 10-58) +- `api/main.py` (lines 246-281) + +Plus similar `_format_duration` in: +- `cli/analytics.py:1394` +- `transcript_analytics.py:149` + +**Action**: +1. Create `src/python/htmlgraph/utils/formatting.py` with canonical implementations +2. Replace all 5 locations with imports from the shared module +3. Add unit tests for formatting functions + +**Existing dependency opportunity**: `humanize` package provides `naturalsize()`, `naturaldelta()`, `intcomma()` which could replace custom `format_bytes`, `format_duration`, `format_number`. However, adding a new dependency for formatting is marginal — the custom code is simple and well-understood. **Recommendation: Keep custom code but consolidate to one location.** + +### 1B. Truncation Utilities → consolidate into `utils/formatting.py` + +**Problem**: 5 different truncation implementations: +- `api/templates.py:86` — `truncate_text` +- `api/filters.py:31` — `truncate_text` +- `error_handler.py:122` — `truncate_if_needed` +- `http_hook.py:163` — `_truncate` +- `ingest/claude_code.py:473` — `_truncate_tool_input` + +**Action**: +1. Create `truncate(text, max_len, suffix="...")` in shared module +2. Create `truncate_recursive(obj, max_len, max_depth)` for nested structures +3. Replace all 5 implementations + +### 1C. JSON Utilities → consolidate to `utils/json.py` + +**Problem**: Two JSON utility modules: +- `json_utils.py` (root, simpler) +- `api/json_utils.py` (comprehensive `JSONHandler` class) + +**Action**: +1. Move comprehensive version to `utils/json.py` +2. Re-export from `api/json_utils.py` for backward compatibility +3. Remove root `json_utils.py`, update imports + +**Existing dependency**: `orjson` is already a dependency and handles fast JSON. The custom code adds validation and subsetting — **keep custom code, just consolidate location.** + +### 1D. Cache Consolidation + +**Problem**: 3 cache implementations: +- `api/cache.py:41` — `QueryCache` (basic TTL) +- `repositories/shared_cache_memory.py:21` — `MemorySharedCache` (LRU + TTL + thread-safe) +- `api/main.py:33` — `QueryCache` (duplicate of cache.py) + +**Action**: +1. Keep `MemorySharedCache` as the canonical implementation (most robust) +2. Make `QueryCache` a thin wrapper or alias +3. Remove duplicate in `api/main.py` + +**Existing dependency**: `fastapi-cache2` is already a dependency for HTTP-level caching. The in-memory caches serve a different purpose (query result caching). **Keep custom cache but eliminate duplicates.** + +--- + +## Phase 2: Critical Module Splits (Parallel Lane B) + +**Goal**: Split the 3 largest modules (>2,000 lines) into focused sub-modules. + +### 2A. `session_manager.py` (2,918 lines → 4-5 modules) + +**Current responsibilities** (God Object): +- Session lifecycle (start/end/resume/suspend) +- Smart attribution scoring +- Drift detection +- WIP limit enforcement +- Auto-completion checking +- Session deduplication +- Activity tracking/linking +- Spike auto-creation +- Error tracking +- HTML serialization + +**Proposed split**: +``` +src/python/htmlgraph/session/ +├── __init__.py # Re-exports for backward compat +├── manager.py # Core lifecycle (~600 lines) +├── attribution.py # Smart attribution scoring (~500 lines) +├── drift.py # Drift detection (~400 lines) +├── linking.py # Activity tracking & linking (~400 lines) +├── wip.py # WIP limits & auto-completion (~300 lines) +└── serialization.py # HTML serialization (~300 lines) +``` + +**Backward compatibility**: Keep `session_manager.py` as a re-export shim during transition, then deprecate. + +### 2B. `models.py` (2,427 lines → 4 modules) + +**Current state**: 18+ unrelated model classes in one file. + +**Proposed split**: +``` +src/python/htmlgraph/models/ +├── __init__.py # Re-exports ALL models (backward compat) +├── base.py # Enums (WorkType, SpikeType, etc.), Node, Edge, Step +├── work_items.py # Spike, Chore, Todo, Graph +├── session.py # Session, ActivityEntry, ErrorEntry, ContextSnapshot +└── analytics.py # Pattern, SessionInsight, AggregatedMetric +``` + +**Note**: `models/session.py` already exists at 814 lines — review for overlap and merge. + +### 2C. `graph.py` (2,082 lines → 3 modules) + +**Current state**: Mixed I/O, algorithms, queries, indexing, transactions. + +**Proposed split**: +``` +src/python/htmlgraph/graph/ +├── __init__.py # Re-exports Graph class +├── core.py # Graph class: I/O, node/edge CRUD (~700 lines) +├── algorithms.py # Already exists (597 lines) — move BFS/shortest-path here +├── queries.py # Already exists (581 lines) — move CSS selector queries here +├── indexing.py # Index management, caching (~300 lines) +└── transactions.py # Snapshot/transaction support (~300 lines) +``` + +**Good news**: `graph/algorithms.py` and `graph/queries.py` already exist as companion modules. The refactoring is partially done — just need to move remaining logic out of `graph.py`. + +--- + +## Phase 3: High-Priority Module Splits (Parallel Lane C) + +**Goal**: Split 7 modules in the 1,000-1,800 line range. + +### 3A. `hooks/event_tracker.py` (1,828 lines) + +Split into: +- `hooks/event_recording.py` — Event persistence to SQLite +- `hooks/event_processor.py` — Event normalization and enrichment +- `hooks/model_detection.py` — AI model identification strategies + +### 3B. `session_context.py` (1,646 lines) + +Split into: +- `session/context_builder.py` — Context assembly for AI agents +- `session/version_check.py` — Installed vs PyPI version checking +- `session/environment.py` — Environment detection & git status + +### 3C. `cli/analytics.py` (1,580 lines) + +Split into separate command files: +``` +cli/commands/ +├── cost_analysis.py +├── cigs_status.py +├── transcript.py +├── sync_docs.py +└── search.py +``` + +### 3D. `api/services.py` (1,403 lines) + +Split into: +- `api/services/activity.py` — ActivityService +- `api/services/orchestration.py` — OrchestrationService +- `api/services/analytics.py` — AnalyticsService + +### 3E. `server.py` (1,434 lines) + +Split into: +- `api/handlers.py` — HTTP request handling +- `api/server.py` — Server lifecycle, port management +- `api/static.py` — Static file and dashboard serving + +### 3F. `cli/core.py` (1,371 lines) + +Split 11+ commands into `cli/commands/` directory (one file per command group). + +### 3G. `hooks/pretooluse.py` (1,313 lines) + +Split into: +- `hooks/pretooluse.py` — Core PreToolUse event creation (~500 lines) +- `hooks/orchestration_validator.py` — CIGS enforcement, validation +- `hooks/task_resolution.py` — Parent resolution, subagent detection + +--- + +## Phase 4: Dependency Optimization (Parallel Lane D) + +**Goal**: Identify custom code that can be replaced by existing dependencies or well-established packages. + +### 4A. Already Well-Utilized Dependencies (No Changes Needed) + +| Dependency | Usage | Assessment | +|------------|-------|------------| +| **pydantic** | Data validation across project | Excellent usage | +| **tenacity** | Retry with backoff in `decorators.py` | Properly wraps tenacity | +| **orjson** | Fast JSON serialization | Used appropriately | +| **structlog** | Structured logging | Good integration | +| **rich** | CLI output formatting | Well-utilized | +| **collections** (stdlib) | 60 imports (Counter, defaultdict, deque) | Heavy, appropriate use | + +### 4B. Standard Library Underutilization + +| Module | Opportunity | Files Affected | +|--------|-------------|----------------| +| **functools.lru_cache** | Memoize expensive analytics computations | `analytics/strategic/pattern_detector.py`, `analytics_index.py` | +| **itertools.batched** (3.12+) / **itertools.islice** | Replace manual chunking loops in batch processing | `hooks/event_tracker.py`, `cli/work/ingest.py` | +| **textwrap.shorten** | Replace some custom truncation logic | `api/templates.py`, `api/filters.py` | +| **dataclasses.asdict** | Replace manual dict conversion in some models | Various model files | + +**Recommendation**: Use `textwrap.shorten()` from stdlib as the base for `truncate_text()` — it handles word boundaries and ellipsis natively. Wrap it in a thin utility for consistent behavior. + +### 4C. Potential New Dependencies — Analysis + +#### **humanize** (for `format_duration`, `format_bytes`, `format_number`) +- **PyPI**: 350M+ downloads/month, actively maintained +- **What it provides**: `naturalsize("1000000")` → "1.0 MB", `naturaldelta(timedelta(hours=3))` → "3 hours" +- **Assessment**: Custom formatting is simple and well-understood (6 functions, ~80 lines total after consolidation). Adding `humanize` would save ~80 lines but add a dependency. +- **Recommendation**: **Keep custom code.** The functions are trivial, well-tested, and adding a dependency for 80 lines of simple formatting is not worth the maintenance burden. + +#### **cachetools** (for cache implementations) +- **PyPI**: 100M+ downloads/month, part of Google's ecosystem +- **What it provides**: `TTLCache`, `LRUCache`, thread-safe decorators +- **Assessment**: `MemorySharedCache` (396 lines) reimplements TTL+LRU cache with thread safety. `cachetools.TTLCache` provides this in ~5 lines of configuration. +- **Recommendation**: **Consider adopting.** This would eliminate ~350 lines of custom cache code and provide battle-tested LRU+TTL eviction. However, the custom cache has HtmlGraph-specific features (metrics, namespace isolation). **Evaluate in a spike** — if >80% of features can use `cachetools`, adopt it. + +#### **radon** (for complexity measurement — dev dependency only) +- **PyPI**: Well-established Python complexity analyzer +- **What it provides**: Cyclomatic complexity, maintainability index, raw metrics per function/class/module +- **Assessment**: Would enable automated complexity checking in CI. +- **Recommendation**: **Add as dev dependency.** Use in the enforcement script (Phase 5) to measure function complexity alongside module line counts. + +#### **wily** (for complexity tracking over time — dev dependency only) +- **PyPI**: Tracks code complexity metrics across git history +- **What it provides**: Complexity trends, diff complexity reports +- **Assessment**: Useful for tracking whether refactoring is reducing complexity. +- **Recommendation**: **Optional.** Nice for dashboarding but not critical. Can add later. + +### 4D. Custom Code to Keep + +| Custom Code | Why Keep | +|-------------|----------| +| `ids.py` (ID generation) | Domain-specific ID format, intentional design | +| `query_builder.py` | Domain-specific CSS selector queries | +| `atomic_ops.py` | Uses stdlib correctly, well-designed | +| `decorators.py` (retry) | Thin wrapper over tenacity, adds domain context | +| `graph/algorithms.py` | Domain-specific graph algorithms | + +--- + +## Phase 5: Standards Enforcement (Parallel Lane E) + +**Goal**: Ensure all future development adheres to module size and quality standards. + +### 5A. Enforcement Script: `scripts/check-module-size.py` + +Checks: +- **Module line count**: Warn >300, fail >500, critical >1000 +- **Function length**: Warn >30, fail >50 +- **Class length**: Warn >200, fail >300 +- **Cyclomatic complexity**: Warn >7, fail >10 (using radon) + +Exit codes: 0 (pass), 1 (warnings), 2 (failures) + +### 5B. Pre-commit Hook Integration + +Add to `.pre-commit-config.yaml`: +```yaml +- repo: local + hooks: + - id: module-size + name: check module sizes + entry: uv run python scripts/check-module-size.py + language: system + pass_filenames: false + files: ^src/python/htmlgraph/ + stages: [pre-commit] +``` + +### 5C. Agent Definition Updates + +Add module size awareness to ALL agent definitions in `packages/claude-plugin/agents/`: + +**All agents** get this standard block: +```markdown +## Module Size Standards +- Target: 200-500 lines per module +- Hard limit: 500 lines for new modules +- If your changes would push a module >500 lines, split it first +- Functions: max 50 lines, target 10-20 +- Classes: max 300 lines, target 100-200 +- One responsibility per module (Single Responsibility Principle) +``` + +**Agent-specific additions**: +- **opus-coder.md**: "When assigned refactoring work, use the split patterns documented in MODULE_REFACTORING_TRACK.md" +- **sonnet-coder.md**: "Before adding to a module >400 lines, evaluate if it should be split first" +- **haiku-coder.md**: "Decline work that would push a module >500 lines — escalate to Sonnet/Opus" +- **test-runner.md**: "After tests pass, run `scripts/check-module-size.py` on changed files" +- **researcher.md**: "When researching a module, note its size and recommend refactoring if >500 lines" +- **debugger.md**: "If a bug is in a module >1000 lines, recommend refactoring as part of the fix" + +### 5D. System Prompt Updates + +Add to `packages/claude-plugin/.claude-plugin/system-prompt-default.md`: +```markdown +## Module Size Standards (Enforced) +- New modules: max 500 lines +- Existing modules: reduce toward 300-500 lines during any modification +- Never add code to a module >1000 lines without splitting first +- Run `scripts/check-module-size.py` before committing +``` + +### 5E. Code Hygiene Rules Update + +Add to `.claude/rules/code-hygiene.md`: +```markdown +## Module Size & Complexity Standards + +### Line Count Limits +| Metric | Target | Warning | Fail | +|--------|--------|---------|------| +| Module | 200-500 | >300 | >500 (new) | +| Function | 10-20 | >30 | >50 | +| Class | 100-200 | >200 | >300 | + +### Enforcement +- `scripts/check-module-size.py` runs in pre-commit +- Existing large modules are grandfathered but tracked for refactoring +- Any modification to a grandfathered module must not increase its size +``` + +--- + +## Parallel Execution Plan + +All 5 phases can execute concurrently across separate branches: + +``` +Week 1-2: +├── Lane A: Utilities consolidation (Phase 1) ← Independent +├── Lane D: Dependency analysis spike (Phase 4) ← Independent +└── Lane E: Enforcement scripts & docs (Phase 5) ← Independent + +Week 3-4: +├── Lane B: Critical splits (Phase 2A-2C) ← After Lane A (shared utils exist) +└── Lane E: Agent/prompt updates (Phase 5C-5E) ← After scripts created + +Week 5-6: +└── Lane C: High-priority splits (Phase 3A-3G) ← After Lane B patterns established + +Ongoing: +└── All lanes: Enforce standards on new code ← After Lane E complete +``` + +### Dependencies Between Lanes + +``` +Lane A (Utils) ──────────────┐ + ├──→ Lane B (Critical Splits) +Lane D (Deps Analysis) ─────┘ │ + ├──→ Lane C (High-Priority Splits) +Lane E (Enforcement) ────────────────┘ +``` + +--- + +## Success Metrics + +| Metric | Current | Target | Measurement | +|--------|---------|--------|-------------| +| Modules >1000 lines | 15 | 0 | `scripts/check-module-size.py` | +| Modules >500 lines | ~30 | <5 (grandfathered) | Same script | +| Duplicated utility code | 5 instances | 0 | Manual audit | +| New dependencies added | 0 | 1-2 (radon, possibly cachetools) | pyproject.toml | +| Agent definitions with size guidance | 0/6 | 6/6 | Manual check | + +--- + +## Risk Mitigation + +1. **Import breakage**: Every split module provides backward-compatible re-exports via `__init__.py` +2. **Test failures**: Run full test suite after each module split; never split without tests passing +3. **Merge conflicts**: Each lane works on different files; coordinate if touching shared imports +4. **Over-engineering**: Don't split modules below 200 lines; don't add abstractions for single-use code +5. **Dependency bloat**: Only add dependencies that replace >200 lines of custom code OR provide critical correctness guarantees + +--- + +## Files Modified by This Track + +### New Files +- `scripts/check-module-size.py` — Enforcement script +- `docs/tracks/MODULE_REFACTORING_TRACK.md` — This document +- `src/python/htmlgraph/utils/formatting.py` — Consolidated formatting +- Multiple new split modules (per Phase 2 & 3) + +### Modified Files +- `.pre-commit-config.yaml` — Add module-size hook +- `.claude/rules/code-hygiene.md` — Add module standards +- `packages/claude-plugin/agents/*.md` — Add size guidance (6 files) +- `packages/claude-plugin/.claude-plugin/system-prompt-default.md` — Add standards +- `AGENTS.md` — Add module organization section +- `pyproject.toml` — Add radon dev dependency diff --git a/packages/claude-plugin/.claude-plugin/marketplace.json b/packages/claude-plugin/.claude-plugin/marketplace.json index e41a43d3..4e701b2b 100644 --- a/packages/claude-plugin/.claude-plugin/marketplace.json +++ b/packages/claude-plugin/.claude-plugin/marketplace.json @@ -6,11 +6,11 @@ "url": "https://github.com/Shakes-tzd" }, "description": "HTML-based agent observability + workflow analytics. Git-first JSONL event tracking with rebuildable SQLite index. Zero external dependencies, works offline, version control friendly.", - "version": "0.33.77", + "version": "0.33.79", "plugins": [ { "name": "htmlgraph", - "version": "0.33.77", + "version": "0.33.79", "source": "../", "description": "HTML-based agent observability + workflow analytics (Git-first JSONL + rebuildable SQLite index)", "category": "Development Tools", diff --git a/packages/claude-plugin/.claude-plugin/plugin.json b/packages/claude-plugin/.claude-plugin/plugin.json index 8932ed36..aa815c78 100644 --- a/packages/claude-plugin/.claude-plugin/plugin.json +++ b/packages/claude-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "htmlgraph", - "version": "0.33.77", + "version": "0.33.79", "description": "HTML-based agent observability + workflow analytics with thin-shell hook architecture (Git-first JSONL + rebuildable SQLite index)", "author": { "name": "Shakes Dlamini" diff --git a/packages/claude-plugin/.claude-plugin/system-prompt-default.md b/packages/claude-plugin/.claude-plugin/system-prompt-default.md index aa502a60..914d11e4 100644 --- a/packages/claude-plugin/.claude-plugin/system-prompt-default.md +++ b/packages/claude-plugin/.claude-plugin/system-prompt-default.md @@ -27,8 +27,15 @@ sdk.features.create("Feature name").save() # Track features sdk.spikes.create("Investigation").set_findings("results").save() # Track research ``` +## Module Size Standards (Enforced) +- New modules: max 500 lines. Functions: max 50 lines. Classes: max 300 lines +- Never add code to a module >1000 lines without splitting it first +- Run `python scripts/check-module-size.py --changed-only` before committing +- Check `src/python/htmlgraph/utils/` for shared utilities before creating new ones +- Prefer stdlib and existing dependencies over custom implementations + ## Quality Gates -Before committing: `uv run ruff check --fix && uv run ruff format && uv run mypy src/ && uv run pytest` +Before committing: `uv run ruff check --fix && uv run ruff format && uv run mypy src/ && uv run pytest && python scripts/check-module-size.py --changed-only` ## Key Rules 1. Read before Write/Edit — always check existing content first diff --git a/packages/claude-plugin/agents/debugger.md b/packages/claude-plugin/agents/debugger.md index adf1e217..b747d82f 100644 --- a/packages/claude-plugin/agents/debugger.md +++ b/packages/claude-plugin/agents/debugger.md @@ -169,6 +169,14 @@ uv --version 4. Ensure hooks are running: PostToolUse should provide reflections 5. Restart Claude Code if needed +## Module Size Awareness + +When debugging issues in large modules: +- If the bug is in a module **>1000 lines**, recommend refactoring as part of the fix +- Large modules are harder to debug — note this as a contributing factor +- Check `docs/tracks/MODULE_REFACTORING_TRACK.md` for planned splits of the affected module +- **Run** `python scripts/check-module-size.py ` to check specific files + ## Output Format Document debugging process in HtmlGraph bug or spike: diff --git a/packages/claude-plugin/agents/haiku-coder.md b/packages/claude-plugin/agents/haiku-coder.md index e138c224..1e34e4cf 100644 --- a/packages/claude-plugin/agents/haiku-coder.md +++ b/packages/claude-plugin/agents/haiku-coder.md @@ -77,6 +77,13 @@ Task( - "Investigate performance bottleneck" ``` +## Module Size Standards + +- **Hard limits**: 500 lines/module (new), 50 lines/function, 300 lines/class +- If your changes would push a module >500 lines, **decline the task** and escalate to Sonnet or Opus for a split-first approach +- **Check** `src/python/htmlgraph/utils/` for existing shared utilities before writing new helpers +- **Never** duplicate formatting, truncation, or caching utilities — they exist in shared modules + ## Cost **$0.80 per million input tokens** diff --git a/packages/claude-plugin/agents/opus-coder.md b/packages/claude-plugin/agents/opus-coder.md index dd32baf1..288cb07d 100644 --- a/packages/claude-plugin/agents/opus-coder.md +++ b/packages/claude-plugin/agents/opus-coder.md @@ -90,6 +90,18 @@ Task( - Use sparingly for tasks that truly need deep reasoning - Overkill for simple or moderate complexity tasks +## Module Size Standards + +When implementing or refactoring code: +- **Target**: 200-500 lines per module, 10-20 lines per function, 100-200 lines per class +- **Hard limits**: 500 lines/module (new), 50 lines/function, 300 lines/class +- **Before adding code** to any module >400 lines, evaluate whether it should be split first +- **When refactoring**: Use split patterns documented in `docs/tracks/MODULE_REFACTORING_TRACK.md` +- **Run** `python scripts/check-module-size.py --changed-only` before committing +- **Never** add code to a module >1000 lines without splitting it first +- **Prefer** existing dependencies and stdlib over custom implementations (check `pyproject.toml`) +- **Consolidate** duplicate utilities — check `src/python/htmlgraph/utils/` before writing new helpers + ## Decision Criteria Ask yourself: diff --git a/packages/claude-plugin/agents/researcher.md b/packages/claude-plugin/agents/researcher.md index 925d9874..2a1e9509 100644 --- a/packages/claude-plugin/agents/researcher.md +++ b/packages/claude-plugin/agents/researcher.md @@ -125,6 +125,14 @@ Your research is automatically tracked via hooks, but you should also: - Future researchers can query what you searched for - This builds institutional knowledge over time +## Module Size Awareness + +When researching a module or codebase area: +- **Note the module's line count** — if >500 lines, recommend refactoring as part of your findings +- **Check for duplicated utilities** — search `src/python/htmlgraph/utils/` before suggesting custom implementations +- **Reference** `docs/tracks/MODULE_REFACTORING_TRACK.md` for existing refactoring plans +- **Prefer** existing dependencies (check `pyproject.toml`) and stdlib over new custom code + ## Output Format Document findings in HtmlGraph spike: diff --git a/packages/claude-plugin/agents/sonnet-coder.md b/packages/claude-plugin/agents/sonnet-coder.md index 43927f97..b7cb97ed 100644 --- a/packages/claude-plugin/agents/sonnet-coder.md +++ b/packages/claude-plugin/agents/sonnet-coder.md @@ -82,6 +82,17 @@ Task( - "Optimize database schema for scale" ``` +## Module Size Standards + +When implementing features: +- **Target**: 200-500 lines per module, 10-20 lines per function, 100-200 lines per class +- **Hard limits**: 500 lines/module (new), 50 lines/function, 300 lines/class +- **Before adding code** to a module >400 lines, evaluate if it should be split first +- If your changes would push a module >500 lines, split it as part of your work +- **Run** `python scripts/check-module-size.py --changed-only` before committing +- **Check** `src/python/htmlgraph/utils/` for existing shared utilities before creating new ones +- **Prefer** stdlib (`textwrap`, `functools.lru_cache`, `itertools`) over custom implementations + ## Cost **$3 per million input tokens** diff --git a/packages/claude-plugin/agents/test-runner.md b/packages/claude-plugin/agents/test-runner.md index 5730ff3d..3099e345 100644 --- a/packages/claude-plugin/agents/test-runner.md +++ b/packages/claude-plugin/agents/test-runner.md @@ -280,6 +280,19 @@ Testing fits into the workflow: - ❌ Writing tests after implementation (TDD backwards) - ❌ Not updating tests when code changes +## Module Size Checks + +After tests pass, also verify module size standards: +```bash +# Check changed files against size limits +python scripts/check-module-size.py --changed-only + +# Full codebase check (summary only) +python scripts/check-module-size.py --summary +``` + +Report module size violations alongside test results. If any changed file exceeds 500 lines (non-grandfathered), flag it as a quality gate failure. + ## Code Hygiene Rules From CLAUDE.md - MANDATORY: diff --git a/packages/claude-plugin/hooks/scripts/user-prompt-submit.py b/packages/claude-plugin/hooks/scripts/user-prompt-submit.py index 3569f768..03b6b940 100755 --- a/packages/claude-plugin/hooks/scripts/user-prompt-submit.py +++ b/packages/claude-plugin/hooks/scripts/user-prompt-submit.py @@ -80,10 +80,7 @@ def main() -> None: # 5. Generate workflow guidance (SDK) workflow_guidance = generate_guidance( - classification, - active_work, - prompt, - open_work_items=open_items, + classification, active_work, prompt, open_work_items=open_items ) # 6. CIGS: Generate imperative delegation guidance (SDK) diff --git a/packages/gemini-extension/gemini-extension.json b/packages/gemini-extension/gemini-extension.json index 3560be37..22c4fdea 100644 --- a/packages/gemini-extension/gemini-extension.json +++ b/packages/gemini-extension/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "htmlgraph", - "version": "0.33.77", + "version": "0.33.79", "description": "HtmlGraph session tracking and documentation for Gemini CLI. Ensures proper activity attribution, feature awareness, and continuous work tracking.", "author": "Shakes", "license": "MIT", diff --git a/packages/phoenix-dashboard/.formatter.exs b/packages/phoenix-dashboard/.formatter.exs new file mode 100644 index 00000000..e945e12b --- /dev/null +++ b/packages/phoenix-dashboard/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/packages/phoenix-dashboard/.gitignore b/packages/phoenix-dashboard/.gitignore new file mode 100644 index 00000000..9083ad54 --- /dev/null +++ b/packages/phoenix-dashboard/.gitignore @@ -0,0 +1,16 @@ +# Elixir/Phoenix +/_build/ +/deps/ +/doc/ +/.fetch +erl_crash.dump +*.ez +*.beam + +# Node/Assets +/assets/node_modules/ +/priv/static/assets/ + +# Generated +*.swp +*~ diff --git a/packages/phoenix-dashboard/.htmlgraph/htmlgraph.db b/packages/phoenix-dashboard/.htmlgraph/htmlgraph.db new file mode 100644 index 00000000..5e6a6b89 Binary files /dev/null and b/packages/phoenix-dashboard/.htmlgraph/htmlgraph.db differ diff --git a/packages/phoenix-dashboard/README.md b/packages/phoenix-dashboard/README.md new file mode 100644 index 00000000..c8cdd180 --- /dev/null +++ b/packages/phoenix-dashboard/README.md @@ -0,0 +1,81 @@ +# HtmlGraph Phoenix Dashboard + +**Exploration: Phoenix LiveView dashboard for HtmlGraph activity feed.** + +This is an exploratory implementation of a real-time activity feed dashboard +built with Phoenix LiveView, replacing the static HTML dashboard with live +WebSocket-driven updates. + +## Architecture + +``` + ┌─────────────────────────┐ + │ Phoenix LiveView App │ + │ │ +┌──────────┐ │ ┌───────────────────┐ │ ┌──────────┐ +│ Claude │──────▶│ │ EventPoller │ │◀──────│ Browser │ +│ Hooks │ │ │ (GenServer) │ │ WS │ Client │ +│ (Python) │ │ └────────┬──────────┘ │ └──────────┘ +└──────────┘ │ │ PubSub │ + │ │ ┌────────▼──────────┐ │ + │ │ │ ActivityFeedLive │ │ + ▼ │ │ (LiveView) │ │ +┌──────────┐ │ └───────────────────┘ │ +│ SQLite │◀──────│ │ +│ .htmlgraph/ │ exqlite (read-only) │ +│ htmlgraph.db └─────────────────────────┘ +└──────────┘ +``` + +## Key Features + +- **Multi-level nesting**: Session → UserQuery → Tool Events → Subagent Events (up to 4 levels) +- **Badges**: Color-coded tool types, models, subagent types, error states, feature links +- **Descending order**: Most recent events first at every level +- **Live updates**: 1-second polling with PubSub broadcast, new events flash green +- **Expand/collapse**: Per-turn and per-event toggle with tree connectors + +## Event Hierarchy + +``` +Session (collapsible group) +└── UserQuery "Fix the database schema" [15 tools] [2.3s] [Opus 4.6] + ├── Read src/schema.py [0.1s] + ├── Edit src/schema.py [0.2s] + ├── Task → researcher-agent [Haiku 4.5] (3) + │ ├── Read docs/api.md + │ ├── Grep "schema" + │ └── WebSearch "SQLite migrations" + ├── Bash "uv run pytest" [1.2s] + └── Write src/migration.py [0.1s] +``` + +## Running (once dependencies are available) + +```bash +cd packages/phoenix-dashboard +mix deps.get +mix phx.server +# Visit http://localhost:4000 +``` + +## Environment Variables + +- `HTMLGRAPH_DB_PATH` — Path to the HtmlGraph SQLite database (default: `../../.htmlgraph/htmlgraph.db`) +- `SECRET_KEY_BASE` — Required in production +- `PORT` — HTTP port (default: 4000) + +## Future: Pythonx Integration + +When Pythonx is added, the dashboard can call HtmlGraph's Python SDK directly: + +```elixir +# Instead of raw SQL queries, call the Python SDK +{:ok, result} = Pythonx.eval(""" +from htmlgraph import SDK +sdk = SDK(agent="phoenix-dashboard") +return sdk.analytics.recommend_next_work() +""") +``` + +This enables using all existing Python analytics without porting them to Elixir. diff --git a/packages/phoenix-dashboard/assets/css/app.css b/packages/phoenix-dashboard/assets/css/app.css new file mode 100644 index 00000000..f000cdde --- /dev/null +++ b/packages/phoenix-dashboard/assets/css/app.css @@ -0,0 +1,502 @@ +@import "tailwindcss"; + +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-hover: #30363d; + --border: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-orange: #d29922; + --accent-red: #f85149; + --accent-purple: #bc8cff; + --accent-cyan: #39d2c0; + --accent-pink: #f778ba; + --radius: 6px; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; +} + +/* Header */ +.header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.header-title { + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.header-title .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.header-meta { + font-size: 12px; + color: var(--text-secondary); +} + +/* Activity Feed Container */ +.feed-container { + max-width: 1400px; + margin: 0 auto; + padding: 16px 24px; +} + +/* Session Group */ +.session-group { + margin-bottom: 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.session-header { + background: var(--bg-secondary); + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + cursor: pointer; +} + +.session-header:hover { + background: var(--bg-tertiary); +} + +.session-info { + display: flex; + align-items: center; + gap: 12px; +} + +.session-subtitle { + background: var(--bg-secondary); + padding: 4px 16px 8px 48px; + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +/* Activity List (replaces table for flexible nesting) */ +.activity-list { + width: 100%; +} + +/* Row styles — flex layout for nesting */ +.activity-row { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); + transition: background 0.15s; + padding: 0 12px; + min-height: 36px; +} + +.activity-row:hover { + background: var(--bg-hover); +} + +.row-toggle { + width: 32px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.row-content { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 6px 0; + gap: 12px; +} + +.row-summary { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.row-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* Parent row (UserQuery) */ +.activity-row.parent-row { + background: var(--bg-secondary); + border-left: 3px solid var(--accent-blue); +} + +.activity-row.parent-row:hover { + background: var(--bg-tertiary); +} + +.activity-row.parent-row .row-content { + padding: 8px 0; +} + +.activity-row.parent-row .summary-text { + font-weight: 500; +} + +/* Child rows — depth indentation + progressive darkening */ +.activity-row.child-row { + border-left: 3px solid rgba(148,163,184,0.3); +} + +.activity-row.child-row.depth-0 { + background: rgba(0,0,0,0.15); + border-left-color: rgba(148,163,184,0.3); +} + +.activity-row.child-row.depth-1 { + background: rgba(0,0,0,0.25); + border-left-color: rgba(148,163,184,0.2); +} + +.activity-row.child-row.depth-2 { + background: rgba(0,0,0,0.35); + border-left-color: rgba(100,116,139,0.15); +} + +.activity-row.child-row.depth-3 { + background: rgba(0,0,0,0.45); + border-left-color: rgba(100,116,139,0.1); +} + +/* Task/error border overrides */ +.activity-row.child-row.border-task { + border-left-color: var(--accent-pink); +} + +.activity-row.child-row.border-error { + border-left-color: var(--accent-red); +} + +/* Toggle button */ +.toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + transition: all 0.15s; + display: inline-flex; + align-items: center; +} + +.toggle-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.toggle-btn .arrow { + display: inline-block; + transition: transform 0.2s; + font-size: 10px; +} + +.toggle-btn .arrow.expanded { + transform: rotate(90deg); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + gap: 4px; + white-space: nowrap; +} + +.badge-error { + background: rgba(248, 81, 73, 0.15); + color: var(--accent-red); +} + +.badge-success { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); +} + +.badge-model { + background: rgba(210, 153, 34, 0.15); + color: var(--accent-orange); + font-size: 10px; +} + +.badge-session { + background: rgba(88, 166, 255, 0.1); + color: var(--accent-blue); + border: 1px solid rgba(88, 166, 255, 0.2); +} + +.badge-status-active { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); +} + +.badge-status-completed { + background: rgba(148, 163, 184, 0.2); + color: #94a3b8; +} + +.badge-status-idle { + background: rgba(251, 191, 36, 0.2); + color: #fbbf24; +} + +.badge-feature { + background: rgba(210, 153, 34, 0.1); + color: var(--accent-orange); + border: 1px solid rgba(210, 153, 34, 0.2); + font-size: 10px; +} + +.badge-subagent { + background: rgba(57, 210, 192, 0.1); + color: var(--accent-cyan); + border: 1px solid rgba(57, 210, 192, 0.2); +} + +.badge-agent { + background: rgba(57, 210, 192, 0.15); + color: var(--accent-cyan); +} + +.badge-count { + background: var(--bg-tertiary); + color: var(--text-secondary); + min-width: 20px; + text-align: center; +} + +/* Tool chip colors */ +.tool-chip { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + font-family: var(--font-mono); + white-space: nowrap; + flex-shrink: 0; +} + +.tool-chip-bash { + background: rgba(34,197,94,0.2); + color: #4ade80; +} + +.tool-chip-read { + background: rgba(96,165,250,0.2); + color: #60a5fa; +} + +.tool-chip-edit { + background: rgba(250,204,21,0.2); + color: #fbbf24; +} + +.tool-chip-write { + background: rgba(34,211,238,0.2); + color: #22d3ee; +} + +.tool-chip-grep { + background: rgba(251,146,60,0.2); + color: #fb923c; +} + +.tool-chip-glob { + background: rgba(168,85,247,0.2); + color: #a855f7; +} + +.tool-chip-task { + background: rgba(236,72,153,0.2); + color: #ec4899; +} + +.tool-chip-stop { + background: rgba(139,148,158,0.2); + color: #8b949e; +} + +.tool-chip-default { + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue); +} + +/* Stats row */ +.stats-badges { + display: flex; + gap: 6px; + align-items: center; +} + +/* Event dot indicator */ +.event-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.event-dot.tool_call { background: var(--accent-blue); } +.event-dot.tool_result { background: var(--accent-green); } +.event-dot.error { background: var(--accent-red); } +.event-dot.task_delegation { background: var(--accent-pink); } +.event-dot.delegation { background: var(--accent-cyan); } +.event-dot.start { background: var(--accent-green); } +.event-dot.end { background: var(--text-muted); } + +/* Summary text */ +.summary-text { + color: var(--text-secondary); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.summary-text.prompt { + color: var(--text-primary); + font-weight: 500; +} + +/* Timestamp */ +.timestamp { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} + +/* Duration */ +.duration { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +/* New event flash animation */ +@keyframes flash-new { + 0% { background: rgba(63, 185, 80, 0.2); } + 100% { background: transparent; } +} + +.activity-row.new-event { + animation: flash-new 2s ease-out; +} + +/* Live indicator */ +.live-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--accent-green); +} + +.live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state h2 { + font-size: 18px; + margin-bottom: 8px; + color: var(--text-primary); +} + +/* Flash messages */ +.flash-group { padding: 0 24px; } +.flash-info { + background: rgba(88, 166, 255, 0.1); + border: 1px solid rgba(88, 166, 255, 0.3); + color: var(--accent-blue); + padding: 8px 16px; + border-radius: var(--radius); + margin-top: 8px; +} +.flash-error { + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + color: var(--accent-red); + padding: 8px 16px; + border-radius: var(--radius); + margin-top: 8px; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--bg-primary); } +::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--bg-hover); } diff --git a/packages/phoenix-dashboard/assets/js/app.js b/packages/phoenix-dashboard/assets/js/app.js new file mode 100644 index 00000000..cc0f1fb8 --- /dev/null +++ b/packages/phoenix-dashboard/assets/js/app.js @@ -0,0 +1,15 @@ +// Minimal LiveView client — connects WebSocket for live updates. +// No build tools needed; served as static JS. + +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + +let liveSocket = new LiveSocket("/live", Socket, { + params: {_csrf_token: csrfToken} +}) + +liveSocket.connect() + +window.liveSocket = liveSocket diff --git a/packages/phoenix-dashboard/config/config.exs b/packages/phoenix-dashboard/config/config.exs new file mode 100644 index 00000000..ad328ac6 --- /dev/null +++ b/packages/phoenix-dashboard/config/config.exs @@ -0,0 +1,36 @@ +import Config + +config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + url: [host: "localhost"], + render_errors: [ + formats: [html: HtmlgraphDashboardWeb.ErrorHTML], + layout: false + ], + pubsub_server: HtmlgraphDashboard.PubSub, + live_view: [signing_salt: "htmlgraph_lv"] + +config :htmlgraph_dashboard, + db_path: System.get_env("HTMLGRAPH_DB_PATH") || "../../.htmlgraph/htmlgraph.db" + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :phoenix, :json_library, Jason + +config :esbuild, + version: "0.25.0", + default: [ + args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +config :tailwind, + version: "4.1.12", + default: [ + args: ~w(--input=css/app.css --output=../priv/static/assets/app.css), + cd: Path.expand("../assets", __DIR__) + ] + +import_config "#{config_env()}.exs" diff --git a/packages/phoenix-dashboard/config/dev.exs b/packages/phoenix-dashboard/config/dev.exs new file mode 100644 index 00000000..81e6b0f1 --- /dev/null +++ b/packages/phoenix-dashboard/config/dev.exs @@ -0,0 +1,16 @@ +import Config + +config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "dev-only-secret-key-base-that-is-at-least-64-bytes-long-for-development-use", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + ] + +config :logger, :console, format: "[$level] $message\n" +config :phoenix, :stacktrace_depth, 20 +config :phoenix, :plug_init_mode, :runtime diff --git a/packages/phoenix-dashboard/config/prod.exs b/packages/phoenix-dashboard/config/prod.exs new file mode 100644 index 00000000..ab36941b --- /dev/null +++ b/packages/phoenix-dashboard/config/prod.exs @@ -0,0 +1,6 @@ +import Config + +config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +config :logger, level: :info diff --git a/packages/phoenix-dashboard/config/runtime.exs b/packages/phoenix-dashboard/config/runtime.exs new file mode 100644 index 00000000..c1a4008c --- /dev/null +++ b/packages/phoenix-dashboard/config/runtime.exs @@ -0,0 +1,21 @@ +import Config + +if config_env() == :prod do + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "localhost" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :htmlgraph_dashboard, HtmlgraphDashboardWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ip: {0, 0, 0, 0}, port: port], + secret_key_base: secret_key_base +end + +config :htmlgraph_dashboard, + db_path: System.get_env("HTMLGRAPH_DB_PATH") || "../../.htmlgraph/htmlgraph.db" diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex new file mode 100644 index 00000000..d0f9232b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/activity.ex @@ -0,0 +1,535 @@ +defmodule HtmlgraphDashboard.Activity do + @moduledoc """ + Queries and structures the activity feed data from the HtmlGraph database. + + Builds a multi-level nested tree: + Session -> UserQuery (conversation turn) -> Tool events -> Subagent events + """ + + alias HtmlgraphDashboard.Repo + + @max_depth 4 + + @doc """ + Fetch recent conversation turns with nested children, grouped by session. + Returns a list of session groups, each containing conversation turns. + """ + def list_activity_feed(opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + session_id = Keyword.get(opts, :session_id, nil) + + # Fetch UserQuery events (conversation turns) — these are the top-level entries + user_queries = fetch_user_queries(limit, session_id) + + # For each UserQuery, recursively fetch children + adopt orphans + turns = + Enum.map(user_queries, fn uq -> + children = fetch_children_with_subagents(uq["event_id"], uq["session_id"], 0) + + # Adopt orphan events that belong to this UserQuery's time window + orphans = fetch_orphan_events(uq, user_queries) + all_children = merge_children_by_timestamp(children, orphans) + + work_item = if uq["feature_id"], do: fetch_feature(uq["feature_id"]), else: nil + + displayed_children = + all_children + |> Enum.map(&sanitize_tree/1) + |> Enum.filter(&has_meaningful_content/1) + + stats = compute_stats(displayed_children) + + %{ + user_query: sanitize_event(uq), + children: displayed_children, + stats: stats, + work_item: work_item + } + end) + + # Group by session + turns + |> Enum.group_by(fn t -> t.user_query["session_id"] end) + |> Enum.map(fn {sid, session_turns} -> + session = fetch_session(sid) + + %{ + session_id: sid, + session: session, + turns: session_turns + } + end) + |> Enum.sort_by( + fn group -> + case group.turns do + [first | _] -> first.user_query["timestamp"] + [] -> "" + end + end, + :desc + ) + end + + @doc """ + Fetch a single event by ID with its full subtree. + """ + def get_event_tree(event_id) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE event_id = ? + """ + + case Repo.query_maps(sql, [event_id]) do + {:ok, [event]} -> + children = fetch_children_with_subagents(event_id, event["session_id"], 0) + {:ok, Map.put(event, "children", children)} + + {:ok, []} -> + {:error, :not_found} + + {:error, reason} -> + {:error, reason} + end + end + + # --- Summary Sanitization --- + + @doc """ + Sanitize a summary string by stripping noise: + - XML tags (task-notification, system-reminder) and their content + - Raw JSON objects (context/metadata dumps) + - Truncate to 120 chars + """ + def sanitize_summary(nil), do: "" + def sanitize_summary(""), do: "" + + def sanitize_summary(text) when is_binary(text) do + trimmed = String.trim(text) + + # Early exit: if string starts with {", it's a raw JSON metadata dump — discard entirely + if String.starts_with?(trimmed, "{\"") do + "" + else + trimmed + |> strip_xml_tags() + |> strip_json_dumps() + |> String.trim() + |> strip_agent_prefix() + |> truncate_text(120) + end + end + + defp strip_xml_tags(text) do + text + # Strip matched pairs first (greedy within each pair) + |> String.replace(~r/[\s\S]*?<\/task-notification>/i, "") + |> String.replace(~r/[\s\S]*?<\/system-reminder>/i, "") + |> String.replace(~r/<[a-zA-Z_-]+>[\s\S]*?<\/[a-zA-Z_-]+>/i, "") + # Strip orphaned opening/closing tags (no matching pair in string) + |> String.replace(~r/<\/?[a-zA-Z_-]+>/i, "") + end + + defp strip_json_dumps(text) do + # If the entire string looks like a JSON object, replace it + trimmed = String.trim(text) + + if String.starts_with?(trimmed, "{") and String.ends_with?(trimmed, "}") do + case Jason.decode(trimmed) do + {:ok, map} when is_map(map) -> + # Extract useful fields if present, otherwise discard + cond do + Map.has_key?(map, "subagent_type") -> + prompt = Map.get(map, "prompt", "") + type = Map.get(map, "subagent_type", "") + + if prompt != "" do + "Task (#{type}): #{prompt}" + else + "Task delegation: #{type}" + end + + Map.has_key?(map, "session_id") -> + # Pure context/metadata dump — discard + "" + + true -> + trimmed + end + + _ -> + trimmed + end + else + text + end + end + + defp strip_agent_prefix(text) do + # Strip agent type prefix like "(htmlgraph:haiku-coder): " since it's shown as a badge + Regex.replace(~r/^\([a-zA-Z0-9:_-]+\):\s*/, text, "") + end + + defp truncate_text(text, max_len) do + if String.length(text) > max_len do + String.slice(text, 0, max_len) <> "..." + else + text + end + end + + defp sanitize_event(event) do + event + |> Map.update("input_summary", "", &sanitize_summary/1) + |> Map.update("output_summary", "", &sanitize_summary/1) + end + + defp sanitize_tree(event) do + event + |> sanitize_event() + |> Map.update("children", [], fn children -> + children || [] + |> Enum.map(&sanitize_tree/1) + |> Enum.filter(&has_meaningful_content/1) + end) + end + + # Check if an event has meaningful content. + # Only filters out known noise events. + # A Bash/Edit/Read/Task event with an empty summary after sanitization is still real work. + defp has_meaningful_content(event) do + tool = event["tool_name"] || "" + # Only filter out known noise events + tool not in ["Stop", "SessionResume", "InstructionsLoaded", "SessionStart", "SessionEnd"] + end + + # --- Private: Data fetching --- + + defp fetch_user_queries(limit, nil) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE tool_name = 'UserQuery' + ORDER BY timestamp DESC + LIMIT ? + """ + + case Repo.query_maps(sql, [limit]) do + {:ok, rows} -> rows + {:error, _} -> [] + end + end + + defp fetch_user_queries(limit, session_id) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE tool_name = 'UserQuery' AND session_id = ? + ORDER BY timestamp DESC + LIMIT ? + """ + + case Repo.query_maps(sql, [session_id, limit]) do + {:ok, rows} -> rows + {:error, _} -> [] + end + end + + defp fetch_children_with_subagents(_parent_id, _session_id, depth) when depth >= @max_depth, + do: [] + + defp fetch_children_with_subagents(parent_id, session_id, depth) do + # Fetch direct children by parent_event_id + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE parent_event_id = ? + AND NOT (tool_name = 'Agent' AND event_type != 'task_delegation') + ORDER BY timestamp DESC + """ + + rows = + case Repo.query_maps(sql, [parent_id]) do + {:ok, rows} -> rows + {:error, _} -> [] + end + + Enum.map(rows, fn row -> + grandchildren = + if row["event_type"] == "task_delegation" do + # For task delegations, also pull subagent session events + subagent_children = + fetch_subagent_events(row["event_id"], session_id, row["subagent_type"], depth + 1) + + direct = fetch_children_with_subagents(row["event_id"], session_id, depth + 1) + merge_children_by_timestamp(direct, subagent_children) + else + fetch_children_with_subagents(row["event_id"], session_id, depth + 1) + end + + row + |> Map.put("children", grandchildren) + |> Map.put("depth", depth) + |> Map.put("descendant_count", count_descendants(grandchildren)) + end) + end + + defp fetch_subagent_events(_task_event_id, _parent_session_id, _subagent_type, depth) + when depth >= @max_depth, + do: [] + + defp fetch_subagent_events(_task_event_id, parent_session_id, subagent_type, depth) do + # Subagent sessions follow the pattern: {parent_session_id}-{agent_name} + # Try multiple patterns to find subagent events + patterns = build_subagent_session_patterns(parent_session_id, subagent_type) + + Enum.flat_map(patterns, fn pattern -> + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE session_id LIKE ? + AND tool_name != 'UserQuery' + AND NOT (tool_name = 'Agent' AND event_type != 'task_delegation') + ORDER BY timestamp DESC + """ + + case Repo.query_maps(sql, [pattern]) do + {:ok, rows} -> + # Only include events that don't already have a parent pointing elsewhere + # (they may already be fetched via parent_event_id) + rows + |> Enum.reject(fn r -> r["parent_event_id"] != nil end) + |> Enum.map(fn r -> + r + |> Map.put("depth", depth) + |> Map.put("children", []) + |> Map.put("descendant_count", 0) + end) + + {:error, _} -> + [] + end + end) + end + + defp build_subagent_session_patterns(parent_session_id, nil) do + ["#{parent_session_id}-%"] + end + + defp build_subagent_session_patterns(parent_session_id, subagent_type) do + # Try exact match first, then wildcard + [ + "#{parent_session_id}-#{subagent_type}%" + ] + end + + # --- Orphan Adoption --- + + defp fetch_orphan_events(user_query, all_user_queries) do + session_id = user_query["session_id"] + uq_timestamp = user_query["timestamp"] + uq_event_id = user_query["event_id"] + + # Find the next UserQuery in the same session (by timestamp) + next_uq = + all_user_queries + |> Enum.filter(fn uq -> + uq["session_id"] == session_id and + uq["timestamp"] > uq_timestamp and + uq["event_id"] != uq_event_id + end) + |> Enum.sort_by(fn uq -> uq["timestamp"] end) + |> List.first() + + # Query for orphan events in the time window + {sql, params} = + if next_uq do + {""" + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE session_id = ? + AND parent_event_id IS NULL + AND tool_name != 'UserQuery' + AND NOT (tool_name = 'Agent' AND event_type != 'task_delegation') + AND timestamp >= ? + AND timestamp < ? + ORDER BY timestamp DESC + """, [session_id, uq_timestamp, next_uq["timestamp"]]} + else + {""" + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE session_id = ? + AND parent_event_id IS NULL + AND tool_name != 'UserQuery' + AND NOT (tool_name = 'Agent' AND event_type != 'task_delegation') + AND timestamp >= ? + ORDER BY timestamp DESC + """, [session_id, uq_timestamp]} + end + + case Repo.query_maps(sql, params) do + {:ok, rows} -> + rows + |> Enum.map(fn row -> + row + |> Map.put("depth", 0) + |> Map.put("children", []) + |> Map.put("descendant_count", 0) + end) + |> Enum.filter(&has_meaningful_content/1) + + {:error, _} -> + [] + end + end + + # --- Helpers --- + + defp merge_children_by_timestamp(list_a, list_b) do + # Deduplicate by event_id, then sort by timestamp descending + (list_a ++ list_b) + |> Enum.uniq_by(fn e -> e["event_id"] end) + |> Enum.sort_by(fn e -> e["timestamp"] end, :desc) + end + + defp count_descendants(children) do + Enum.reduce(children, 0, fn child, acc -> + acc + 1 + (child["descendant_count"] || count_descendants(child["children"] || [])) + end) + end + + defp compute_stats(children) do + flat = flatten_children(children) + + %{ + tool_count: length(flat), + total_duration: + flat + |> Enum.map(fn c -> c["execution_duration_seconds"] || 0 end) + |> Enum.sum() + |> to_float() + |> Float.round(2), + success_count: + Enum.count(flat, fn c -> c["status"] in ["recorded", "success", "completed"] end), + error_count: Enum.count(flat, fn c -> c["event_type"] == "error" end), + models: + flat |> Enum.map(fn c -> c["model"] end) |> Enum.reject(&is_nil/1) |> Enum.uniq(), + total_tokens: + flat + |> Enum.map(fn c -> c["cost_tokens"] || 0 end) + |> Enum.sum() + } + end + + defp to_float(value) when is_float(value), do: value + defp to_float(value) when is_integer(value), do: value * 1.0 + + defp flatten_children(children) do + Enum.flat_map(children, fn child -> + [child | flatten_children(child["children"] || [])] + end) + end + + defp fetch_session(nil), do: nil + + defp fetch_session(session_id) do + sql = """ + SELECT session_id, agent_assigned, status, created_at, completed_at, + total_events, total_tokens_used, is_subagent, last_user_query, + model + FROM sessions + WHERE session_id = ? + """ + + case Repo.query_maps(sql, [session_id]) do + {:ok, [session]} -> derive_session_status(session) + _ -> nil + end + end + + defp derive_session_status(session) do + cond do + # If completed_at is set, it's completed + session["completed_at"] != nil -> + Map.put(session, "status", "completed") + + # If status is already explicitly set to something other than active, keep it + session["status"] not in [nil, "active"] -> + session + + # Check if the session's last event is older than 30 minutes + true -> + case last_event_timestamp(session["session_id"]) do + nil -> + session + + ts_string -> + case NaiveDateTime.from_iso8601(ts_string) do + {:ok, last_event_ts} -> + cutoff = NaiveDateTime.add(NaiveDateTime.utc_now(), -30, :minute) + + if NaiveDateTime.compare(last_event_ts, cutoff) == :lt do + Map.put(session, "status", "idle") + else + session + end + + _ -> + session + end + end + end + end + + defp last_event_timestamp(nil), do: nil + + defp last_event_timestamp(session_id) do + sql = """ + SELECT MAX(timestamp) AS last_ts + FROM agent_events + WHERE session_id = ? + """ + + case Repo.query_maps(sql, [session_id]) do + {:ok, [%{"last_ts" => ts}]} -> ts + _ -> nil + end + end + + defp fetch_feature(nil), do: nil + + defp fetch_feature(feature_id) do + sql = """ + SELECT id, type, title, status, priority + FROM features + WHERE id = ? + """ + + case Repo.query_maps(sql, [feature_id]) do + {:ok, [feature]} -> feature + _ -> nil + end + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex new file mode 100644 index 00000000..c50847b3 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/application.ex @@ -0,0 +1,22 @@ +defmodule HtmlgraphDashboard.Application do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + children = [ + {Phoenix.PubSub, name: HtmlgraphDashboard.PubSub}, + HtmlgraphDashboardWeb.Endpoint, + {HtmlgraphDashboard.EventPoller, []} + ] + + opts = [strategy: :one_for_one, name: HtmlgraphDashboard.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + HtmlgraphDashboardWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex new file mode 100644 index 00000000..5723b179 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/event_poller.ex @@ -0,0 +1,89 @@ +defmodule HtmlgraphDashboard.EventPoller do + @moduledoc """ + Polls the HtmlGraph SQLite database for new events and broadcasts + them via Phoenix PubSub for live updates. + + Checks every 1 second for new events since last poll. + """ + use GenServer + + alias HtmlgraphDashboard.Repo + + @poll_interval_ms 1_000 + @topic "activity_feed" + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Subscribe to live event updates." + def subscribe do + Phoenix.PubSub.subscribe(HtmlgraphDashboard.PubSub, @topic) + end + + @impl true + def init(_opts) do + state = %{ + last_event_id: nil, + last_timestamp: nil + } + + schedule_poll() + {:ok, state} + end + + @impl true + def handle_info(:poll, state) do + new_state = poll_new_events(state) + schedule_poll() + {:noreply, new_state} + end + + defp schedule_poll do + Process.send_after(self(), :poll, @poll_interval_ms) + end + + defp poll_new_events(%{last_timestamp: nil} = state) do + # First poll — just record the latest timestamp, don't broadcast history + case Repo.query("SELECT timestamp FROM agent_events ORDER BY timestamp DESC LIMIT 1") do + {:ok, [[ts]]} -> + %{state | last_timestamp: ts} + + _ -> + state + end + end + + defp poll_new_events(%{last_timestamp: last_ts} = state) do + sql = """ + SELECT event_id, tool_name, event_type, timestamp, input_summary, + output_summary, session_id, agent_id, parent_event_id, + subagent_type, model, status, cost_tokens, + execution_duration_seconds, feature_id, context + FROM agent_events + WHERE timestamp > ? + ORDER BY timestamp ASC + LIMIT 100 + """ + + case Repo.query_maps(sql, [last_ts]) do + {:ok, []} -> + state + + {:ok, events} -> + Enum.each(events, fn event -> + Phoenix.PubSub.broadcast( + HtmlgraphDashboard.PubSub, + @topic, + {:new_event, event} + ) + end) + + latest = List.last(events) + %{state | last_timestamp: latest["timestamp"]} + + {:error, _reason} -> + state + end + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex new file mode 100644 index 00000000..1ae3789c --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard/repo.ex @@ -0,0 +1,117 @@ +defmodule HtmlgraphDashboard.Repo do + @moduledoc """ + Direct SQLite3 reader for the HtmlGraph database. + + Read-only access to the existing .htmlgraph/htmlgraph.db file. + Uses exqlite for lightweight SQLite3 connectivity. + """ + + @doc """ + Returns the configured database path, resolved relative to the app root. + """ + def db_path do + path = Application.get_env(:htmlgraph_dashboard, :db_path, "../../.htmlgraph/htmlgraph.db") + + if Path.type(path) == :relative do + Path.join(File.cwd!(), path) + |> Path.expand() + else + path + end + end + + @doc """ + Execute a read-only query against the HtmlGraph database. + Returns {:ok, rows} or {:error, reason}. + """ + def query(sql, params \\ []) do + path = db_path() + + case Exqlite.Sqlite3.open(path, [:readonly]) do + {:ok, conn} -> + try do + execute_query(conn, sql, params) + after + Exqlite.Sqlite3.close(conn) + end + + {:error, reason} -> + {:error, {:open_failed, reason, path}} + end + end + + @doc """ + Execute a query and return rows as maps with column name keys. + """ + def query_maps(sql, params \\ []) do + path = db_path() + + case Exqlite.Sqlite3.open(path, [:readonly]) do + {:ok, conn} -> + try do + case Exqlite.Sqlite3.prepare(conn, sql) do + {:ok, stmt} -> + bind_params(conn, stmt, params) + rows = collect_rows(conn, stmt) + columns = get_columns(conn, stmt) + Exqlite.Sqlite3.release(conn, stmt) + + maps = + Enum.map(rows, fn row -> + columns + |> Enum.zip(row) + |> Map.new() + end) + + {:ok, maps} + + {:error, reason} -> + {:error, reason} + end + after + Exqlite.Sqlite3.close(conn) + end + + {:error, reason} -> + {:error, {:open_failed, reason, path}} + end + end + + defp execute_query(conn, sql, params) do + case Exqlite.Sqlite3.prepare(conn, sql) do + {:ok, stmt} -> + bind_params(conn, stmt, params) + rows = collect_rows(conn, stmt) + Exqlite.Sqlite3.release(conn, stmt) + {:ok, rows} + + {:error, reason} -> + {:error, reason} + end + end + + defp bind_params(_conn, _stmt, []), do: :ok + + defp bind_params(_conn, stmt, params) do + Exqlite.Sqlite3.bind(stmt, params) + end + + defp collect_rows(conn, stmt) do + collect_rows(conn, stmt, []) + end + + defp collect_rows(conn, stmt, acc) do + case Exqlite.Sqlite3.step(conn, stmt) do + {:row, row} -> collect_rows(conn, stmt, [row | acc]) + :done -> Enum.reverse(acc) + {:error, _reason} -> Enum.reverse(acc) + end + end + + defp get_columns(conn, stmt) do + case Exqlite.Sqlite3.columns(conn, stmt) do + {:ok, column_names} -> column_names + {:error, _reason} -> [] + end + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex new file mode 100644 index 00000000..6690f25a --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web.ex @@ -0,0 +1,87 @@ +defmodule HtmlgraphDashboardWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: HtmlgraphDashboardWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {HtmlgraphDashboardWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, only: [get_csrf_token: 0] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: HtmlgraphDashboardWeb.Endpoint, + router: HtmlgraphDashboardWeb.Router, + statics: HtmlgraphDashboardWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex new file mode 100644 index 00000000..305b7f8b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/core_components.ex @@ -0,0 +1,19 @@ +defmodule HtmlgraphDashboardWeb.CoreComponents do + @moduledoc """ + Minimal core components for the dashboard. + """ + use Phoenix.Component + + def flash_group(assigns) do + ~H""" +
    +

    + <%= Phoenix.Flash.get(@flash, :info) %> +

    +

    + <%= Phoenix.Flash.get(@flash, :error) %> +

    +
    + """ + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex new file mode 100644 index 00000000..80e45e49 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts.ex @@ -0,0 +1,7 @@ +defmodule HtmlgraphDashboardWeb.Layouts do + use HtmlgraphDashboardWeb, :html + + import HtmlgraphDashboardWeb.CoreComponents + + embed_templates "layouts/*" +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex new file mode 100644 index 00000000..f07c1944 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/app.html.heex @@ -0,0 +1,4 @@ +
    + <.flash_group flash={@flash} /> + {@inner_content} +
    diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex new file mode 100644 index 00000000..1e4bf8f1 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/components/layouts/root.html.heex @@ -0,0 +1,14 @@ + + + + + + + HtmlGraph — Activity Feed + + + + + {@inner_content} + + diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex new file mode 100644 index 00000000..58ebf851 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/endpoint.ex @@ -0,0 +1,32 @@ +defmodule HtmlgraphDashboardWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :htmlgraph_dashboard + + @session_options [ + store: :cookie, + key: "_htmlgraph_dashboard_key", + signing_salt: "htmlgraph_salt", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :htmlgraph_dashboard, + gzip: false, + only: HtmlgraphDashboardWeb.static_paths() + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug HtmlgraphDashboardWeb.Router +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex new file mode 100644 index 00000000..6465808b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/error_html.ex @@ -0,0 +1,7 @@ +defmodule HtmlgraphDashboardWeb.ErrorHTML do + use HtmlgraphDashboardWeb, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex new file mode 100644 index 00000000..0c2f36a8 --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/live/activity_feed_live.ex @@ -0,0 +1,508 @@ +defmodule HtmlgraphDashboardWeb.ActivityFeedLive do + @moduledoc """ + Live activity feed with multi-level nested events, badges, and real-time updates. + + Architecture: + - Polls SQLite database via EventPoller GenServer + - Receives new events via PubSub broadcast + - Maintains expand/collapse state per conversation turn + - Multi-level nesting: Session > UserQuery > Tool Events > Subagent Events + """ + use HtmlgraphDashboardWeb, :live_view + + alias HtmlgraphDashboard.Activity + alias HtmlgraphDashboard.EventPoller + + @impl true + def mount(params, _session, socket) do + if connected?(socket) do + EventPoller.subscribe() + end + + session_id = params["session_id"] + + socket = + socket + |> assign(:session_filter, session_id) + |> assign(:expanded, MapSet.new()) + |> assign(:reload_timer, nil) + |> load_feed() + + {:ok, socket} + end + + @impl true + def handle_params(params, _uri, socket) do + session_id = params["session_id"] + + socket = + socket + |> assign(:session_filter, session_id) + |> load_feed() + + {:noreply, socket} + end + + @impl true + def handle_event("toggle", %{"event-id" => event_id}, socket) do + expanded = socket.assigns.expanded + + expanded = + if MapSet.member?(expanded, event_id) do + MapSet.delete(expanded, event_id) + else + MapSet.put(expanded, event_id) + end + + {:noreply, assign(socket, :expanded, expanded)} + end + + def handle_event("toggle_session", %{"session-id" => session_id}, socket) do + expanded = socket.assigns.expanded + key = "session:#{session_id}" + + expanded = + if MapSet.member?(expanded, key) do + MapSet.delete(expanded, key) + else + MapSet.put(expanded, key) + end + + {:noreply, assign(socket, :expanded, expanded)} + end + + @impl true + def handle_info({:new_event, _event}, socket) do + # Debounce: schedule a single reload 500ms from now + # Cancel any existing pending reload to avoid redundant work + if socket.assigns[:reload_timer] do + Process.cancel_timer(socket.assigns.reload_timer) + end + + timer = Process.send_after(self(), :do_reload, 500) + {:noreply, assign(socket, :reload_timer, timer)} + end + + def handle_info(:do_reload, socket) do + socket = + socket + |> assign(:reload_timer, nil) + |> load_feed() + + {:noreply, socket} + end + + defp load_feed(socket) do + opts = + case socket.assigns[:session_filter] do + nil -> [limit: 50] + sid -> [limit: 50, session_id: sid] + end + + feed = Activity.list_activity_feed(opts) + total_events = feed |> Enum.map(fn g -> length(g.turns) end) |> Enum.sum() + + socket + |> assign(:feed, feed) + |> assign(:total_events, total_events) + end + + # --- Template Helpers --- + + defp tool_chip_class(tool_name) do + case tool_name do + "Bash" -> "tool-chip tool-chip-bash" + "Read" -> "tool-chip tool-chip-read" + "Edit" -> "tool-chip tool-chip-edit" + "Write" -> "tool-chip tool-chip-write" + "Grep" -> "tool-chip tool-chip-grep" + "Glob" -> "tool-chip tool-chip-glob" + "Task" -> "tool-chip tool-chip-task" + "Agent" -> "tool-chip tool-chip-task" + "TodoWrite" -> "tool-chip tool-chip-edit" + "TodoRead" -> "tool-chip tool-chip-read" + "TaskCreate" -> "tool-chip tool-chip-task" + "TaskOutput" -> "tool-chip tool-chip-task" + "Stop" -> "tool-chip tool-chip-stop" + _ -> "tool-chip tool-chip-default" + end + end + + defp event_dot_class(event_type) do + case event_type do + "error" -> "error" + "task_delegation" -> "task_delegation" + "delegation" -> "delegation" + "tool_result" -> "tool_result" + _ -> "tool_call" + end + end + + defp format_timestamp(nil), do: "" + + defp format_timestamp(ts) when is_binary(ts) do + case Regex.run(~r/(\d{2}:\d{2}:\d{2})/, ts) do + [_, time] -> time + _ -> ts + end + end + + defp format_duration(nil), do: "" + defp format_duration(+0.0), do: "" + + defp format_duration(seconds) when is_number(seconds) do + cond do + seconds < 1 -> "#{round(seconds * 1000)}ms" + seconds < 60 -> "#{Float.round(seconds * 1.0, 1)}s" + true -> "#{round(seconds / 60)}m" + end + end + + defp truncate(nil, _), do: "" + + defp truncate(text, max_len) when is_binary(text) do + if String.length(text) > max_len do + String.slice(text, 0, max_len) <> "..." + else + text + end + end + + defp has_children?(event) do + children = event["children"] || [] + length(children) > 0 + end + + defp descendant_count(event) do + event["descendant_count"] || child_count(event) + end + + defp child_count(event) do + children = event["children"] || [] + length(children) + end + + defp is_expanded?(expanded, event_id) do + MapSet.member?(expanded, event_id) + end + + defp session_expanded?(expanded, session_id) do + MapSet.member?(expanded, "session:#{session_id}") + end + + defp depth_class(depth) do + case depth do + 0 -> "depth-0" + 1 -> "depth-1" + 2 -> "depth-2" + _ -> "depth-3" + end + end + + defp is_task_event?(event) do + event["event_type"] == "task_delegation" or + (event["tool_name"] == "Task" and event["subagent_type"] != nil) + end + + defp row_border_class(event) do + cond do + is_task_event?(event) -> "border-task" + event["event_type"] == "error" -> "border-error" + true -> "" + end + end + + defp summary_text(event) do + input = event["input_summary"] || "" + output = event["output_summary"] || "" + + cond do + input != "" -> input + output != "" -> output + true -> "" + end + end + + defp session_title(group) do + # Prefer last_user_query from the session record + lq = group.session && group.session["last_user_query"] + + if is_binary(lq) and String.trim(lq) != "" do + truncate(String.trim(lq), 80) + else + # Fall back to first turn's prompt text + case group.turns do + [first | _] -> + text = first.user_query["input_summary"] || "" + + if String.trim(text) != "" do + truncate(text, 80) + else + truncate(group.session_id, 12) + end + + [] -> + truncate(group.session_id, 12) + end + end + end + + defp agent_label(nil), do: nil + defp agent_label("system"), do: nil + defp agent_label(""), do: nil + defp agent_label(name), do: name + + defp format_relative_time(nil), do: "" + + defp format_relative_time(ts) when is_binary(ts) do + case NaiveDateTime.from_iso8601(ts) do + {:ok, ndt} -> + diff = NaiveDateTime.diff(NaiveDateTime.utc_now(), ndt, :second) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86400 -> "#{div(diff, 3600)}h ago" + true -> "#{div(diff, 86400)}d ago" + end + + _ -> + format_timestamp(ts) + end + end + + @impl true + def render(assigns) do + ~H""" +
    +
    + + HtmlGraph Activity Feed +
    +
    +
    + + Live +
    +
    + <%= @total_events %> conversation turns +
    +
    +
    + +
    + <%= if @feed == [] do %> +
    +

    No activity yet

    +

    Events will appear here as agents work. The feed updates in real-time.

    +
    + <% else %> + <%= for group <- @feed do %> +
    + +
    +
    + + + ▶ + + + + <%= session_title(group) %> + + <%= if group.session do %> + + <%= group.session["status"] || "active" %> + + <%= if agent_label(group.session["agent_assigned"]) do %> + + <%= agent_label(group.session["agent_assigned"]) %> + + <% end %> + <% end %> +
    +
    + + <%= length(group.turns) %> turns + + <%= if group.session do %> + + <%= group.session["total_events"] || 0 %> events + + <% end %> +
    +
    + + +
    + <%= if group.session do %> + + Started: <%= format_relative_time(group.session["created_at"]) %> + + + <%= truncate(group.session_id, 16) %> + + <%= if group.session["model"] do %> + <%= group.session["model"] %> + <% end %> + <% end %> +
    + + +
    + <%= for turn <- group.turns do %> + +
    +
    + <%= if length(turn.children) > 0 do %> + + <% end %> +
    +
    +
    + + <%= truncate(turn.user_query["input_summary"], 100) %> + +
    +
    + + <%= turn.stats.tool_count %> tools + + <%= if turn.stats.error_count > 0 do %> + + <%= turn.stats.error_count %> errors + + <% end %> + <%= if turn.work_item do %> + + <%= truncate(turn.work_item["title"], 30) %> + + <% end %> + <%= for model <- turn.stats.models do %> + <%= model %> + <% end %> + + <%= format_timestamp(turn.user_query["timestamp"]) %> + + + <%= format_duration(turn.stats.total_duration) %> + +
    +
    +
    + + + <%= if is_expanded?(@expanded, turn.user_query["event_id"]) do %> + <%= for child <- turn.children do %> + <.event_row + event={child} + expanded={@expanded} + /> + <% end %> + <% end %> + <% end %> +
    +
    + <% end %> + <% end %> +
    + """ + end + + defp event_row(assigns) do + ~H""" +
    +
    + <%= if has_children?(@event) do %> + + <% end %> +
    +
    +
    + + + + <%= @event["tool_name"] %> + + + <%= truncate(summary_text(@event), 80) %> + +
    +
    + <%= if @event["subagent_type"] do %> + + <%= @event["subagent_type"] %> + + <% end %> + <%= if @event["model"] do %> + + <%= @event["model"] %> + + <% end %> + <%= if @event["event_type"] == "error" do %> + error + <% end %> + <%= if has_children?(@event) do %> + + (<%= descendant_count(@event) %>) + + <% end %> + + <%= format_timestamp(@event["timestamp"]) %> + + + <%= format_duration(@event["execution_duration_seconds"]) %> + +
    +
    +
    + + + <%= if is_expanded?(@expanded, @event["event_id"]) do %> + <%= for child <- (@event["children"] || []) do %> + <.event_row + event={child} + expanded={@expanded} + /> + <% end %> + <% end %> + """ + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex new file mode 100644 index 00000000..31e9748b --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/router.ex @@ -0,0 +1,22 @@ +defmodule HtmlgraphDashboardWeb.Router do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.LiveView.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {HtmlgraphDashboardWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + scope "/", HtmlgraphDashboardWeb do + pipe_through :browser + + live "/", ActivityFeedLive, :index + live "/session/:session_id", ActivityFeedLive, :session + end +end diff --git a/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex new file mode 100644 index 00000000..32bacb1c --- /dev/null +++ b/packages/phoenix-dashboard/lib/htmlgraph_dashboard_web/styles.ex @@ -0,0 +1,492 @@ +defmodule HtmlgraphDashboardWeb.Styles do + @moduledoc "Inline CSS for the dashboard. No build tools needed." + + def css do + ~S""" + :root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-hover: #30363d; + --border: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-orange: #d29922; + --accent-red: #f85149; + --accent-purple: #bc8cff; + --accent-cyan: #39d2c0; + --accent-pink: #f778ba; + --radius: 6px; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + + body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + } + + /* Header */ + .header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; + } + + .header-title { + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + + .header-title .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + .header-meta { + font-size: 12px; + color: var(--text-secondary); + } + + /* Activity Feed Container */ + .feed-container { + max-width: 1400px; + margin: 0 auto; + padding: 16px 24px; + } + + /* Session Group */ + .session-group { + margin-bottom: 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + } + + .session-header { + background: var(--bg-secondary); + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + cursor: pointer; + } + + .session-header:hover { + background: var(--bg-tertiary); + } + + .session-info { + display: flex; + align-items: center; + gap: 12px; + } + + /* Activity List (replaces table for flexible nesting) */ + .activity-list { + width: 100%; + } + + /* Row styles — flex layout for nesting */ + .activity-row { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); + transition: background 0.15s; + padding: 0 12px; + min-height: 36px; + } + + .activity-row:hover { + background: var(--bg-hover); + } + + .row-toggle { + width: 32px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .row-content { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 6px 0; + gap: 12px; + } + + .row-summary { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + } + + .row-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + /* Parent row (UserQuery) */ + .activity-row.parent-row { + background: var(--bg-secondary); + border-left: 3px solid var(--accent-blue); + } + + .activity-row.parent-row:hover { + background: var(--bg-tertiary); + } + + .activity-row.parent-row .row-content { + padding: 8px 0; + } + + .activity-row.parent-row .summary-text { + font-weight: 500; + } + + /* Child rows — depth indentation + progressive darkening */ + .activity-row.child-row { + border-left: 3px solid rgba(148,163,184,0.3); + } + + .activity-row.child-row.depth-0 { + background: rgba(0,0,0,0.15); + border-left-color: rgba(148,163,184,0.3); + } + + .activity-row.child-row.depth-1 { + background: rgba(0,0,0,0.25); + border-left-color: rgba(148,163,184,0.2); + } + + .activity-row.child-row.depth-2 { + background: rgba(0,0,0,0.35); + border-left-color: rgba(100,116,139,0.15); + } + + .activity-row.child-row.depth-3 { + background: rgba(0,0,0,0.45); + border-left-color: rgba(100,116,139,0.1); + } + + /* Task/error border overrides */ + .activity-row.child-row.border-task { + border-left-color: var(--accent-pink); + } + + .activity-row.child-row.border-error { + border-left-color: var(--accent-red); + } + + /* Toggle button */ + .toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + transition: all 0.15s; + display: inline-flex; + align-items: center; + } + + .toggle-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .toggle-btn .arrow { + display: inline-block; + transition: transform 0.2s; + font-size: 10px; + } + + .toggle-btn .arrow.expanded { + transform: rotate(90deg); + } + + /* Badges */ + .badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + gap: 4px; + white-space: nowrap; + } + + .badge-error { + background: rgba(248, 81, 73, 0.15); + color: var(--accent-red); + } + + .badge-success { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); + } + + .badge-model { + background: rgba(210, 153, 34, 0.15); + color: var(--accent-orange); + font-size: 10px; + } + + .badge-session { + background: rgba(88, 166, 255, 0.1); + color: var(--accent-blue); + border: 1px solid rgba(88, 166, 255, 0.2); + } + + .badge-status-active { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); + } + + .badge-status-completed { + background: rgba(139, 148, 158, 0.15); + color: var(--text-secondary); + } + + .badge-feature { + background: rgba(210, 153, 34, 0.1); + color: var(--accent-orange); + border: 1px solid rgba(210, 153, 34, 0.2); + font-size: 10px; + } + + .badge-subagent { + background: rgba(57, 210, 192, 0.1); + color: var(--accent-cyan); + border: 1px solid rgba(57, 210, 192, 0.2); + } + + .badge-agent { + background: rgba(57, 210, 192, 0.15); + color: var(--accent-cyan); + } + + .badge-count { + background: var(--bg-tertiary); + color: var(--text-secondary); + min-width: 20px; + text-align: center; + } + + /* Tool chip colors */ + .tool-chip { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + font-family: var(--font-mono); + white-space: nowrap; + flex-shrink: 0; + } + + .tool-chip-bash { + background: rgba(34,197,94,0.2); + color: #4ade80; + } + + .tool-chip-read { + background: rgba(96,165,250,0.2); + color: #60a5fa; + } + + .tool-chip-edit { + background: rgba(250,204,21,0.2); + color: #fbbf24; + } + + .tool-chip-write { + background: rgba(34,211,238,0.2); + color: #22d3ee; + } + + .tool-chip-grep { + background: rgba(251,146,60,0.2); + color: #fb923c; + } + + .tool-chip-glob { + background: rgba(168,85,247,0.2); + color: #a855f7; + } + + .tool-chip-task { + background: rgba(236,72,153,0.2); + color: #ec4899; + } + + .tool-chip-stop { + background: rgba(139,148,158,0.2); + color: #8b949e; + } + + .tool-chip-default { + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue); + } + + /* Stats row */ + .stats-badges { + display: flex; + gap: 6px; + align-items: center; + } + + /* Event dot indicator */ + .event-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + } + + .event-dot.tool_call { background: var(--accent-blue); } + .event-dot.tool_result { background: var(--accent-green); } + .event-dot.error { background: var(--accent-red); } + .event-dot.task_delegation { background: var(--accent-pink); } + .event-dot.delegation { background: var(--accent-cyan); } + .event-dot.start { background: var(--accent-green); } + .event-dot.end { background: var(--text-muted); } + + /* Summary text */ + .summary-text { + color: var(--text-secondary); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + .summary-text.prompt { + color: var(--text-primary); + font-weight: 500; + } + + /* Timestamp */ + .timestamp { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + } + + /* Duration */ + .duration { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + } + + /* New event flash animation */ + @keyframes flash-new { + 0% { background: rgba(63, 185, 80, 0.2); } + 100% { background: transparent; } + } + + .activity-row.new-event { + animation: flash-new 2s ease-out; + } + + /* Live indicator */ + .live-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--accent-green); + } + + .live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-green); + animation: pulse 2s ease-in-out infinite; + } + + /* Empty state */ + .empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); + } + + .empty-state h2 { + font-size: 18px; + margin-bottom: 8px; + color: var(--text-primary); + } + + /* Flash messages */ + .flash-group { padding: 0 24px; } + .flash-info { + background: rgba(88, 166, 255, 0.1); + border: 1px solid rgba(88, 166, 255, 0.3); + color: var(--accent-blue); + padding: 8px 16px; + border-radius: var(--radius); + margin-top: 8px; + } + .flash-error { + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + color: var(--accent-red); + padding: 8px 16px; + border-radius: var(--radius); + margin-top: 8px; + } + + /* Scrollbar */ + ::-webkit-scrollbar { width: 8px; } + ::-webkit-scrollbar-track { background: var(--bg-primary); } + ::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: var(--bg-hover); } + """ + end +end diff --git a/packages/phoenix-dashboard/mix.exs b/packages/phoenix-dashboard/mix.exs new file mode 100644 index 00000000..2ccb143d --- /dev/null +++ b/packages/phoenix-dashboard/mix.exs @@ -0,0 +1,45 @@ +defmodule HtmlgraphDashboard.MixProject do + use Mix.Project + + def project do + [ + app: :htmlgraph_dashboard, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + mod: {HtmlgraphDashboard.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp deps do + [ + {:phoenix, "~> 1.8"}, + {:phoenix_html, "~> 4.2"}, + {:phoenix_live_view, "~> 1.1"}, + {:phoenix_live_reload, "~> 1.6", only: :dev}, + {:phoenix_live_dashboard, "~> 0.8"}, + {:exqlite, "~> 0.13"}, + {:jason, "~> 1.4"}, + {:plug_cowboy, "~> 2.6"}, + {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + end + + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind default", "esbuild default"], + "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] + ] + end +end diff --git a/packages/phoenix-dashboard/mix.lock b/packages/phoenix-dashboard/mix.lock new file mode 100644 index 00000000..157282e6 --- /dev/null +++ b/packages/phoenix-dashboard/mix.lock @@ -0,0 +1,30 @@ +%{ + "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/pyproject.toml b/pyproject.toml index 17a631cf..8d1379fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "htmlgraph" -version = "0.33.77" +version = "0.33.79" description = "Local-first observability and coordination platform for AI-assisted development. Document-backed work items with SQLite-indexed runtime state, live FastAPI/HTMX dashboard, and multi-agent session tracking." authors = [{name = "Shakes", email = "shakestzd@gmail.com"}] readme = "README.md" diff --git a/scripts/check-module-size.py b/scripts/check-module-size.py new file mode 100755 index 00000000..d54600e9 --- /dev/null +++ b/scripts/check-module-size.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Module size and complexity enforcement script. + +Checks Python modules against industry-standard size limits: +- Module line count: warn >300, fail >500 (new modules), critical >1000 +- Function length: warn >30, fail >50 +- Class length: warn >200, fail >300 + +Usage: + python scripts/check-module-size.py # Check all modules + python scripts/check-module-size.py --changed-only # Check only git-changed files + python scripts/check-module-size.py --fail-on-warning # Strict mode + python scripts/check-module-size.py --json # JSON output + python scripts/check-module-size.py --summary # Summary table only + python scripts/check-module-size.py path/to/file.py # Check specific files + +Exit codes: + 0 - All checks pass + 1 - Warnings found (non-blocking by default) + 2 - Failures found (blocking) +""" + +from __future__ import annotations + +import argparse +import ast +import json +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# --- Configuration --- + +SRC_DIR = Path("src/python/htmlgraph") + +# Module line count thresholds +MODULE_WARN = 300 +MODULE_FAIL = 500 +MODULE_CRITICAL = 1000 + +# Function line count thresholds +FUNCTION_WARN = 30 +FUNCTION_FAIL = 50 + +# Class line count thresholds +CLASS_WARN = 200 +CLASS_FAIL = 300 + +# Grandfathered modules: these existed before standards were enforced. +# They are tracked but don't cause failures. Any modification must not +# increase their size. Remove entries as modules are refactored. +GRANDFATHERED_MODULES: set[str] = { + "session_manager.py", + "models.py", + "graph.py", + "hooks/event_tracker.py", + "session_context.py", + "cli/analytics.py", + "server.py", + "api/services.py", + "cli/core.py", + "hooks/pretooluse.py", + "api/routes/dashboard.py", + "cli/work/ingest.py", + "planning/models.py", + "planning.py", + "agents.py", +} + + +@dataclass +class Issue: + file: str + kind: str # "module", "function", "class" + name: str + lines: int + level: str # "warning", "failure", "critical" + threshold: int + grandfathered: bool = False + + +@dataclass +class FileReport: + path: str + total_lines: int + functions: list[tuple[str, int]] = field(default_factory=list) + classes: list[tuple[str, int]] = field(default_factory=list) + issues: list[Issue] = field(default_factory=list) + + +def count_lines(filepath: Path) -> int: + """Count non-empty, non-comment lines in a Python file.""" + try: + text = filepath.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return 0 + count = 0 + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + count += 1 + return count + + +def analyze_ast(filepath: Path) -> tuple[list[tuple[str, int]], list[tuple[str, int]]]: + """Extract function and class sizes from AST.""" + try: + text = filepath.read_text(encoding="utf-8") + tree = ast.parse(text, filename=str(filepath)) + except (OSError, SyntaxError, UnicodeDecodeError): + return [], [] + + functions: list[tuple[str, int]] = [] + classes: list[tuple[str, int]] = [] + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + size = node.end_lineno - node.lineno + 1 if node.end_lineno else 0 + functions.append((node.name, size)) + elif isinstance(node, ast.ClassDef): + size = node.end_lineno - node.lineno + 1 if node.end_lineno else 0 + classes.append((node.name, size)) + + return functions, classes + + +def get_relative_path(filepath: Path) -> str: + """Get path relative to SRC_DIR.""" + try: + return str(filepath.relative_to(SRC_DIR)) + except ValueError: + return str(filepath) + + +def is_grandfathered(rel_path: str) -> bool: + """Check if a module is grandfathered.""" + return rel_path in GRANDFATHERED_MODULES + + +def check_file(filepath: Path) -> FileReport: + """Check a single file against all thresholds.""" + rel_path = get_relative_path(filepath) + total_lines = count_lines(filepath) + functions, classes = analyze_ast(filepath) + grandfathered = is_grandfathered(rel_path) + + report = FileReport( + path=rel_path, + total_lines=total_lines, + functions=functions, + classes=classes, + ) + + # Check module size + if total_lines > MODULE_CRITICAL: + report.issues.append( + Issue(rel_path, "module", rel_path, total_lines, "critical", MODULE_CRITICAL, grandfathered) + ) + elif total_lines > MODULE_FAIL: + report.issues.append( + Issue(rel_path, "module", rel_path, total_lines, "failure", MODULE_FAIL, grandfathered) + ) + elif total_lines > MODULE_WARN: + report.issues.append( + Issue(rel_path, "module", rel_path, total_lines, "warning", MODULE_WARN, grandfathered) + ) + + # Check function sizes + for name, size in functions: + if size > FUNCTION_FAIL: + report.issues.append(Issue(rel_path, "function", name, size, "failure", FUNCTION_FAIL)) + elif size > FUNCTION_WARN: + report.issues.append(Issue(rel_path, "function", name, size, "warning", FUNCTION_WARN)) + + # Check class sizes + for name, size in classes: + if size > CLASS_FAIL: + report.issues.append(Issue(rel_path, "class", name, size, "failure", CLASS_FAIL)) + elif size > CLASS_WARN: + report.issues.append(Issue(rel_path, "class", name, size, "warning", CLASS_WARN)) + + return report + + +def get_changed_files() -> list[Path]: + """Get Python files changed in git (staged + unstaged).""" + try: + result = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + files = result.stdout.strip().splitlines() + # Also check staged files + result2 = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=ACMR", "--cached"], + capture_output=True, + text=True, + check=True, + ) + files.extend(result2.stdout.strip().splitlines()) + return [ + Path(f) + for f in set(files) + if f.startswith(str(SRC_DIR)) and f.endswith(".py") + ] + except subprocess.CalledProcessError: + return [] + + +def get_all_python_files() -> list[Path]: + """Get all Python files in SRC_DIR.""" + return sorted(SRC_DIR.rglob("*.py")) + + +def print_summary(reports: list[FileReport]) -> None: + """Print a summary table of oversized modules.""" + oversized = [r for r in reports if r.total_lines > MODULE_WARN] + if not oversized: + print("\nAll modules are within size limits.") + return + + oversized.sort(key=lambda r: r.total_lines, reverse=True) + + print(f"\n{'Module':<55} {'Lines':>6} {'Status':<12} {'Note'}") + print("-" * 90) + for r in oversized: + rel = r.path + grandfathered = is_grandfathered(rel) + + if r.total_lines > MODULE_CRITICAL: + status = "CRITICAL" + elif r.total_lines > MODULE_FAIL: + status = "FAIL" + else: + status = "WARN" + + note = "(grandfathered)" if grandfathered else "" + print(f"{rel:<55} {r.total_lines:>6} {status:<12} {note}") + + total_over = len(oversized) + critical = sum(1 for r in oversized if r.total_lines > MODULE_CRITICAL) + failures = sum(1 for r in oversized if MODULE_FAIL < r.total_lines <= MODULE_CRITICAL) + warnings = sum(1 for r in oversized if MODULE_WARN < r.total_lines <= MODULE_FAIL) + + print(f"\nTotal: {total_over} oversized modules ({critical} critical, {failures} failures, {warnings} warnings)") + + +def print_issues(reports: list[FileReport], *, verbose: bool = True) -> None: + """Print all issues found.""" + all_issues = [] + for r in reports: + all_issues.extend(r.issues) + + if not all_issues: + print("No issues found.") + return + + # Group by level + for level in ("critical", "failure", "warning"): + level_issues = [i for i in all_issues if i.level == level and not i.grandfathered] + if not level_issues: + continue + + icon = {"critical": "!!!", "failure": "XX", "warning": "~~"}[level] + print(f"\n{icon} {level.upper()} ({len(level_issues)} issues):") + for issue in sorted(level_issues, key=lambda i: -i.lines): + print(f" {issue.file}: {issue.kind} '{issue.name}' is {issue.lines} lines (limit: {issue.threshold})") + + # Show grandfathered separately + gf_issues = [i for i in all_issues if i.grandfathered] + if gf_issues and verbose: + print(f"\n** GRANDFATHERED ({len(gf_issues)} modules tracked for refactoring):") + for issue in sorted(gf_issues, key=lambda i: -i.lines): + print(f" {issue.file}: {issue.lines} lines (target: <{MODULE_FAIL})") + + +def to_json(reports: list[FileReport]) -> str: + """Convert reports to JSON.""" + data = { + "thresholds": { + "module": {"warn": MODULE_WARN, "fail": MODULE_FAIL, "critical": MODULE_CRITICAL}, + "function": {"warn": FUNCTION_WARN, "fail": FUNCTION_FAIL}, + "class": {"warn": CLASS_WARN, "fail": CLASS_FAIL}, + }, + "summary": { + "total_files": len(reports), + "total_issues": sum(len(r.issues) for r in reports), + "oversized_modules": sum(1 for r in reports if r.total_lines > MODULE_WARN), + }, + "files": [ + { + "path": r.path, + "lines": r.total_lines, + "issues": [ + { + "kind": i.kind, + "name": i.name, + "lines": i.lines, + "level": i.level, + "threshold": i.threshold, + "grandfathered": i.grandfathered, + } + for i in r.issues + ], + } + for r in reports + if r.issues + ], + } + return json.dumps(data, indent=2) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check Python module sizes against standards") + parser.add_argument("files", nargs="*", help="Specific files to check (default: all)") + parser.add_argument("--changed-only", action="store_true", help="Only check git-changed files") + parser.add_argument("--fail-on-warning", action="store_true", help="Treat warnings as failures") + parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON") + parser.add_argument("--summary", action="store_true", help="Show summary table only") + parser.add_argument("--no-grandfathered", action="store_true", help="Don't show grandfathered modules") + args = parser.parse_args() + + # Determine which files to check + if args.files: + files = [Path(f) for f in args.files if f.endswith(".py")] + elif args.changed_only: + files = get_changed_files() + if not files: + print("No changed Python files found.") + return 0 + else: + files = get_all_python_files() + + # Skip __init__.py and test files + files = [f for f in files if f.name != "__init__.py" and "test" not in str(f)] + + # Analyze + reports = [check_file(f) for f in files] + + # Output + if args.json_output: + print(to_json(reports)) + elif args.summary: + print_summary(reports) + else: + print(f"Checked {len(reports)} Python modules in {SRC_DIR}/") + print_summary(reports) + print_issues(reports, verbose=not args.no_grandfathered) + + # Determine exit code + non_gf_issues = [ + i for r in reports for i in r.issues if not i.grandfathered + ] + + has_failures = any(i.level in ("failure", "critical") for i in non_gf_issues) + has_warnings = any(i.level == "warning" for i in non_gf_issues) + + if has_failures: + return 2 + if has_warnings and args.fail_on_warning: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/python/htmlgraph/__init__.py b/src/python/htmlgraph/__init__.py index de0f3f64..436f4ddd 100644 --- a/src/python/htmlgraph/__init__.py +++ b/src/python/htmlgraph/__init__.py @@ -138,7 +138,7 @@ ) from htmlgraph.work_type_utils import infer_work_type, infer_work_type_from_id -__version__ = "0.33.77" +__version__ = "0.33.79" __all__ = [ # Exceptions "HtmlGraphError", diff --git a/src/python/htmlgraph/analytics/critical_path.py b/src/python/htmlgraph/analytics/critical_path.py new file mode 100644 index 00000000..f3777a93 --- /dev/null +++ b/src/python/htmlgraph/analytics/critical_path.py @@ -0,0 +1,493 @@ +""" +Critical Path Analysis for HtmlGraph. + +Analyzes feature dependency graphs to find critical paths and bottlenecks, +using the `graph_edges` and `features` SQLite tables. + +Functions: +- find_critical_path(track_id, db_path) -> list[str] +- find_bottlenecks(track_id, db_path) -> list[dict] +- get_dependency_graph(track_id, db_path) -> dict +""" + +from __future__ import annotations + +import logging +import sqlite3 +from collections import defaultdict, deque +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Relationship types that indicate "A blocks B" (edge from A -> B means A must be done first) +_BLOCKS_RELATIONSHIPS = {"blocks", "blocked_by"} + + +def _get_default_db_path() -> str: + """Return the default database path for the current project.""" + import os + import subprocess + + env_dir = os.environ.get("HTMLGRAPH_PROJECT_DIR") or os.environ.get( + "CLAUDE_PROJECT_DIR" + ) + if env_dir: + return str(Path(env_dir) / ".htmlgraph" / "htmlgraph.db") + + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + project_root = Path(result.stdout.strip()) + return str(project_root / ".htmlgraph" / "htmlgraph.db") + except Exception: + pass + + return str(Path.home() / ".htmlgraph" / "htmlgraph.db") + + +def _connect(db_path: str) -> sqlite3.Connection: + """Open a read-friendly SQLite connection.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def _load_features( + cursor: sqlite3.Cursor, track_id: str | None +) -> dict[str, dict[str, Any]]: + """ + Load features from the database. + + Returns a mapping of feature_id -> feature dict with keys: + id, title, status, type. + """ + if track_id is not None: + cursor.execute( + "SELECT id, title, status, type FROM features WHERE track_id = ?", + (track_id,), + ) + else: + cursor.execute("SELECT id, title, status, type FROM features") + + return {row["id"]: dict(row) for row in cursor.fetchall()} + + +def _load_edges( + cursor: sqlite3.Cursor, feature_ids: set[str] +) -> list[tuple[str, str, str]]: + """ + Load dependency edges from graph_edges for the given set of feature IDs. + + Returns list of (from_node_id, to_node_id, relationship_type) tuples. + + Edge semantics: + - relationship_type == "blocks": from_node blocks to_node (from must finish first) + - relationship_type == "blocked_by": from_node is blocked_by to_node (to must finish first) + """ + if not feature_ids: + return [] + + placeholders = ",".join("?" for _ in feature_ids) + cursor.execute( + f""" + SELECT from_node_id, to_node_id, relationship_type + FROM graph_edges + WHERE relationship_type IN ('blocks', 'blocked_by') + AND from_node_id IN ({placeholders}) + AND to_node_id IN ({placeholders}) + """, + list(feature_ids) + list(feature_ids), + ) + return [ + (row["from_node_id"], row["to_node_id"], row["relationship_type"]) + for row in cursor.fetchall() + ] + + +def _build_adjacency( + feature_ids: set[str], + edges: list[tuple[str, str, str]], +) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """ + Build adjacency lists from raw edges. + + Normalises both "blocks" and "blocked_by" into a single directed graph + where an edge A -> B means "A must be completed before B". + + Returns: + successors: {node_id: [nodes that depend on this node]} + predecessors: {node_id: [nodes this node depends on]} + """ + successors: dict[str, list[str]] = defaultdict(list) + predecessors: dict[str, list[str]] = defaultdict(list) + + for fid in feature_ids: + successors.setdefault(fid, []) + predecessors.setdefault(fid, []) + + for from_id, to_id, rel in edges: + if from_id not in feature_ids or to_id not in feature_ids: + continue + + if rel == "blocks": + # from_id blocks to_id => from_id -> to_id + if to_id not in successors[from_id]: + successors[from_id].append(to_id) + if from_id not in predecessors[to_id]: + predecessors[to_id].append(from_id) + elif rel == "blocked_by": + # from_id is blocked by to_id => to_id -> from_id + if from_id not in successors[to_id]: + successors[to_id].append(from_id) + if to_id not in predecessors[from_id]: + predecessors[from_id].append(to_id) + + return dict(successors), dict(predecessors) + + +def _detect_cycles( + feature_ids: set[str], + successors: dict[str, list[str]], +) -> list[list[str]]: + """ + Detect cycles using DFS coloring (white/gray/black). + + Returns list of cycles found (each cycle is a list of node IDs). + """ + white, gray, black = 0, 1, 2 + color: dict[str, int] = {fid: white for fid in feature_ids} + cycles: list[list[str]] = [] + + def dfs(node: str, path: list[str]) -> None: + color[node] = gray + path.append(node) + for neighbor in successors.get(node, []): + if color[neighbor] == gray: + # Found a cycle — record it + cycle_start = path.index(neighbor) + cycles.append(path[cycle_start:] + [neighbor]) + elif color[neighbor] == white: + dfs(neighbor, path) + path.pop() + color[node] = black + + for fid in feature_ids: + if color[fid] == white: + dfs(fid, []) + + return cycles + + +def _topological_sort_dag( + feature_ids: set[str], + successors: dict[str, list[str]], + predecessors: dict[str, list[str]], +) -> list[str] | None: + """ + Kahn's algorithm for topological sort. + + Returns ordered list, or None if a cycle prevents full ordering. + """ + in_degree = {fid: len(predecessors.get(fid, [])) for fid in feature_ids} + queue: deque[str] = deque(n for n in feature_ids if in_degree[n] == 0) + order: list[str] = [] + + while queue: + node = queue.popleft() + order.append(node) + for succ in successors.get(node, []): + in_degree[succ] -= 1 + if in_degree[succ] == 0: + queue.append(succ) + + if len(order) != len(feature_ids): + return None # cycle detected + + return order + + +def _longest_path( + topo_order: list[str], + successors: dict[str, list[str]], +) -> list[str]: + """ + Compute the longest path in a DAG using dynamic programming over topo order. + + Returns the sequence of node IDs forming the critical path. + """ + dist: dict[str, int] = {node: 1 for node in topo_order} + prev: dict[str, str | None] = {node: None for node in topo_order} + + for node in topo_order: + for succ in successors.get(node, []): + if dist[node] + 1 > dist[succ]: + dist[succ] = dist[node] + 1 + prev[succ] = node + + if not dist: + return [] + + # Find the end of the longest path + end_node = max(dist, key=lambda n: dist[n]) + + # Reconstruct path by walking back through `prev` + path: list[str] = [] + current: str | None = end_node + while current is not None: + path.append(current) + current = prev[current] + + path.reverse() + return path + + +def find_critical_path( + track_id: str | None = None, + db_path: str | None = None, +) -> list[str]: + """ + Find the critical path through feature dependencies for a track. + + The critical path is the longest chain of dependent features — completing + it determines the minimum time to finish the track. + + Algorithm: + 1. Load all features in the track from the `features` table. + 2. Load dependency edges from the `graph_edges` table. + 3. Detect cycles; if found, log a warning and proceed with a DAG approximation. + 4. Run topological sort + DP longest-path on the resulting DAG. + + Args: + track_id: Track ID to scope the analysis (None = all features). + db_path: Path to the SQLite database. Defaults to project database. + + Returns: + Ordered list of feature_ids on the critical path (first = no deps, + last = terminal). Returns [] when there are no features or no edges. + """ + resolved_db = db_path or _get_default_db_path() + + try: + conn = _connect(resolved_db) + except sqlite3.Error as exc: + logger.error("Cannot open database %s: %s", resolved_db, exc) + return [] + + try: + cursor = conn.cursor() + features = _load_features(cursor, track_id) + + if not features: + return [] + + feature_ids = set(features) + raw_edges = _load_edges(cursor, feature_ids) + except sqlite3.Error as exc: + logger.error("Database error loading features/edges: %s", exc) + return [] + finally: + conn.close() + + if not raw_edges: + # No dependency edges — each feature is independent, critical path is any single node + return [] + + successors, predecessors = _build_adjacency(feature_ids, raw_edges) + + # Detect and report cycles before attempting topo sort + cycles = _detect_cycles(feature_ids, successors) + if cycles: + cycle_strs = [" -> ".join(c) for c in cycles] + logger.warning( + "Cycle(s) detected in dependency graph: %s. " + "Cycle members will be excluded from critical path.", + cycle_strs, + ) + # Remove cyclic nodes from consideration + cyclic_nodes: set[str] = set() + for cycle in cycles: + cyclic_nodes.update(cycle) + + feature_ids -= cyclic_nodes + if not feature_ids: + return [] + + # Rebuild adjacency without cyclic nodes + successors, predecessors = _build_adjacency(feature_ids, raw_edges) + + topo_order = _topological_sort_dag(feature_ids, successors, predecessors) + if topo_order is None: + logger.error("Topological sort failed despite cycle removal; returning []") + return [] + + return _longest_path(topo_order, successors) + + +def find_bottlenecks( + track_id: str | None = None, + db_path: str | None = None, +) -> list[dict[str, Any]]: + """ + Identify features that block the most other features. + + Ranks features by transitive dependent count — features that directly or + indirectly block many others surface first. + + Args: + track_id: Track ID to scope the analysis (None = all features). + db_path: Path to the SQLite database. Defaults to project database. + + Returns: + List of dicts sorted descending by transitive_dependents:: + + [ + { + "feature_id": "feat-abc", + "title": "Auth system", + "blocks_count": 3, + "transitive_dependents": 7, + }, + ... + ] + """ + resolved_db = db_path or _get_default_db_path() + + try: + conn = _connect(resolved_db) + except sqlite3.Error as exc: + logger.error("Cannot open database %s: %s", resolved_db, exc) + return [] + + try: + cursor = conn.cursor() + features = _load_features(cursor, track_id) + + if not features: + return [] + + feature_ids = set(features) + raw_edges = _load_edges(cursor, feature_ids) + except sqlite3.Error as exc: + logger.error("Database error: %s", exc) + return [] + finally: + conn.close() + + successors, _predecessors = _build_adjacency(feature_ids, raw_edges) + + def _count_transitive(start: str) -> int: + """BFS count of all nodes reachable from start via successors.""" + visited: set[str] = set() + queue: deque[str] = deque(successors.get(start, [])) + while queue: + node = queue.popleft() + if node in visited: + continue + visited.add(node) + queue.extend(successors.get(node, [])) + return len(visited) + + results = [] + for fid, feat in features.items(): + direct_blocked = successors.get(fid, []) + if not direct_blocked: + continue # No direct dependents — not a bottleneck + + transitive = _count_transitive(fid) + results.append( + { + "feature_id": fid, + "title": feat["title"], + "blocks_count": len(direct_blocked), + "transitive_dependents": transitive, + } + ) + + results.sort(key=lambda r: r["transitive_dependents"], reverse=True) + return results + + +def get_dependency_graph( + track_id: str | None = None, + db_path: str | None = None, +) -> dict[str, Any]: + """ + Return a dependency graph structure suitable for visualization. + + Args: + track_id: Track ID to scope the analysis (None = all features). + db_path: Path to the SQLite database. Defaults to project database. + + Returns: + Dictionary with ``nodes`` and ``edges`` keys:: + + { + "nodes": [ + {"id": "feat-abc", "title": "...", "status": "todo", "type": "feature"}, + ... + ], + "edges": [ + {"from": "feat-abc", "to": "feat-def", "relationship": "blocks"}, + ... + ], + } + """ + resolved_db = db_path or _get_default_db_path() + + try: + conn = _connect(resolved_db) + except sqlite3.Error as exc: + logger.error("Cannot open database %s: %s", resolved_db, exc) + return {"nodes": [], "edges": []} + + try: + cursor = conn.cursor() + features = _load_features(cursor, track_id) + + if not features: + return {"nodes": [], "edges": []} + + feature_ids = set(features) + raw_edges = _load_edges(cursor, feature_ids) + except sqlite3.Error as exc: + logger.error("Database error: %s", exc) + return {"nodes": [], "edges": []} + finally: + conn.close() + + nodes = [ + { + "id": fid, + "title": feat["title"], + "status": feat["status"], + "type": feat["type"], + } + for fid, feat in features.items() + ] + + # Normalise edges: "blocks" A->B means from=A, to=B + # "blocked_by" A<-B (B blocks A) means from=B, to=A + seen_edges: set[tuple[str, str]] = set() + edges = [] + for from_id, to_id, rel in raw_edges: + if rel == "blocks": + key = (from_id, to_id) + if key not in seen_edges: + seen_edges.add(key) + edges.append({"from": from_id, "to": to_id, "relationship": "blocks"}) + elif rel == "blocked_by": + # from_id is blocked_by to_id => to_id blocks from_id + key = (to_id, from_id) + if key not in seen_edges: + seen_edges.add(key) + edges.append({"from": to_id, "to": from_id, "relationship": "blocks"}) + + return {"nodes": nodes, "edges": edges} diff --git a/src/python/htmlgraph/builders/base.py b/src/python/htmlgraph/builders/base.py index 2c87a2ce..20684dec 100644 --- a/src/python/htmlgraph/builders/base.py +++ b/src/python/htmlgraph/builders/base.py @@ -101,9 +101,9 @@ def set_status(self, status: str) -> BuilderT: self._data["status"] = status return self # type: ignore - def add_step(self, description: str) -> BuilderT: + def add_step(self, description: str, step_id: str | None = None) -> BuilderT: """Add a single implementation step.""" - self._data["steps"].append(Step(description=description)) + self._data["steps"].append(Step(description=description, step_id=step_id)) return self # type: ignore def add_steps(self, descriptions: list[str]) -> BuilderT: @@ -125,20 +125,21 @@ def blocked_by(self, node_id: str) -> BuilderT: """Add blocked-by relationship (this node is blocked by another).""" return self._add_edge("blocked_by", node_id) # type: ignore - def relates_to(self, other_id: str, rel_type: str) -> BuilderT: - """Add a typed relationship edge to another node. + def relates_to(self, node_id: str) -> BuilderT: + """Add relates-to relationship.""" + return self._add_edge("relates_to", node_id) # type: ignore - Args: - other_id: Target node ID - rel_type: Relationship type string (e.g., 'depends_on', 'related_to') + def spawned_from(self, node_id: str) -> BuilderT: + """Add spawned-from relationship (provenance).""" + return self._add_edge("spawned_from", node_id) # type: ignore - Returns: - Self for method chaining + def caused_by(self, node_id: str) -> BuilderT: + """Add caused-by relationship (causation).""" + return self._add_edge("caused_by", node_id) # type: ignore - Example: - >>> feature.relates_to("feat-001", "depends_on").relates_to("feat-002", "related_to") - """ - return self._add_edge(rel_type, other_id) # type: ignore + def implements(self, node_id: str) -> BuilderT: + """Add implements relationship (links implementation to spec).""" + return self._add_edge("implements", node_id) # type: ignore def set_track(self, track_id: str) -> BuilderT: """Link to a track.""" @@ -188,6 +189,12 @@ def save(self) -> Node: title=self._data.get("title", ""), ) + # Auto-assign step_ids to any steps that don't have one yet + node_id = self._data["id"] + for i, step in enumerate(self._data.get("steps", [])): + if isinstance(step, Step) and not step.step_id: + step.step_id = f"step-{node_id}-{i}" + # Validate track_id requirement for features node_type = self._data.get("type", self.node_type) if node_type == "feature" and not self._data.get("track_id"): diff --git a/src/python/htmlgraph/collections/base.py b/src/python/htmlgraph/collections/base.py index 81c50a4d..9dc57386 100644 --- a/src/python/htmlgraph/collections/base.py +++ b/src/python/htmlgraph/collections/base.py @@ -825,6 +825,261 @@ def release(self, node_id: str, agent: str | None = None) -> Node | None: graph.update(node) return node + # ------------------------------------------------------------------ + # Graph Edge Operations (dual-write: HTML + SQLite) + # ------------------------------------------------------------------ + + def add_edge( + self, + from_id: str, + to_id: str, + relationship: str, + title: str | None = None, + ) -> str | None: + """ + Add a typed edge between two nodes with dual-write. + + Writes the edge to: + 1. The source node's HTML file (via Node.add_edge) + 2. The SQLite graph_edges table (if DB available) + + Args: + from_id: Source node ID + to_id: Target node ID + relationship: Relationship type (blocks, relates_to, etc.) + title: Optional human-readable title for the edge + + Returns: + Edge ID from SQLite if DB write succeeded, None otherwise + """ + from htmlgraph.models import Edge + + # 1. Update HTML: load source node, add edge, save + graph = self._ensure_graph() + source_node = graph.get(from_id) + if source_node: + edge = Edge(target_id=to_id, relationship=relationship, title=title) + source_node.add_edge(edge) + graph.update(source_node) + + # 2. Write to SQLite if DB is available + edge_id: str | None = None + try: + db = self._sdk._db + if db: + # Infer node types from IDs + from_type = self._infer_node_type(from_id) + to_type = self._infer_node_type(to_id) + edge_id = db.insert_graph_edge( + from_node_id=from_id, + from_node_type=from_type, + to_node_id=to_id, + to_node_type=to_type, + relationship_type=relationship, + ) + except Exception as e: + logger.debug(f"SQLite edge write failed: {e}") + + return edge_id + + def related_to(self, node_id: str) -> list[Any]: + """ + Get nodes related to the given node via any relationship. + + Checks both HTML edges (in-memory graph) and SQLite edges. + + Args: + node_id: Node ID to find related nodes for + + Returns: + List of related Node objects (deduplicated) + """ + related_ids: set[str] = set() + + # Check HTML edges on the source node + graph = self._ensure_graph() + node = graph.get(node_id) + if node: + for edge_list in node.edges.values(): + for edge in edge_list: + related_ids.add(edge.target_id) + + # Check SQLite edges (both directions) + try: + db = self._sdk._db + if db: + for edge_dict in db.get_graph_edges(node_id, direction="both"): + if edge_dict["from_node_id"] == node_id: + related_ids.add(edge_dict["to_node_id"]) + else: + related_ids.add(edge_dict["from_node_id"]) + except Exception as e: + logger.debug(f"SQLite edge query failed: {e}") + + # Resolve to Node objects + result = [] + for rid in related_ids: + related_node = graph.get(rid) + if related_node: + result.append(related_node) + return result + + def query_blocked_by(self, node_id: str) -> list[Any]: + """ + Get nodes that are blocking the given node. + + Args: + node_id: Node ID to find blockers for + + Returns: + List of blocking Node objects + """ + return self._query_edges_by_relationship(node_id, "blocked_by") + + def query_blocks(self, node_id: str) -> list[Any]: + """ + Get nodes that the given node blocks. + + Args: + node_id: Node ID to find blocked nodes for + + Returns: + List of blocked Node objects + """ + return self._query_edges_by_relationship(node_id, "blocks") + + def edges_of( + self, + node_id: str, + relationship: str | None = None, + ) -> list[dict[str, Any]]: + """ + Get all edges for a node, optionally filtered by relationship type. + + Merges edges from both HTML and SQLite sources. + + Args: + node_id: Node ID to query edges for + relationship: Optional relationship type filter + + Returns: + List of edge dictionaries with keys: + source, target_id, relationship, title + """ + edges: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + + # HTML edges from in-memory node + graph = self._ensure_graph() + node = graph.get(node_id) + if node: + for rel_type, edge_list in node.edges.items(): + if relationship and rel_type != relationship: + continue + for edge in edge_list: + key = (node_id, edge.target_id, edge.relationship) + if key not in seen: + seen.add(key) + edges.append( + { + "from_id": node_id, + "target_id": edge.target_id, + "relationship": edge.relationship, + "title": edge.title, + "source": "html", + } + ) + + # SQLite edges + try: + db = self._sdk._db + if db: + db_edges = db.get_graph_edges( + node_id, + direction="both", + relationship_type=relationship, + ) + for e in db_edges: + from_id = e["from_node_id"] + to_id = e["to_node_id"] + rel = e["relationship_type"] + key = (from_id, to_id, rel) + if key not in seen: + seen.add(key) + edges.append( + { + "from_id": from_id, + "target_id": to_id, + "relationship": rel, + "title": None, + "source": "sqlite", + "edge_id": e["edge_id"], + } + ) + except Exception as ex: + logger.debug(f"SQLite edge query failed: {ex}") + + return edges + + def _query_edges_by_relationship( + self, node_id: str, relationship: str + ) -> list[Any]: + """ + Internal helper: get target nodes for a specific relationship. + + Args: + node_id: Source node ID + relationship: Relationship type to filter + + Returns: + List of target Node objects + """ + target_ids: set[str] = set() + + # HTML edges + graph = self._ensure_graph() + node = graph.get(node_id) + if node: + for edge in node.edges.get(relationship, []): + target_ids.add(edge.target_id) + + # SQLite edges (outgoing only for directional relationships) + try: + db = self._sdk._db + if db: + db_edges = db.get_graph_edges( + node_id, + direction="outgoing", + relationship_type=relationship, + ) + for e in db_edges: + target_ids.add(e["to_node_id"]) + except Exception as e: + logger.debug(f"SQLite edge query failed: {e}") + + result = [] + for tid in target_ids: + target_node = graph.get(tid) + if target_node: + result.append(target_node) + return result + + @staticmethod + def _infer_node_type(node_id: str) -> str: + """ + Infer node type from ID prefix. + + Args: + node_id: Node ID like 'feat-abc123' or 'bug-xyz789' + + Returns: + Node type string (feature, bug, spike, etc.) + """ + from htmlgraph.ids import PREFIX_TO_TYPE + + prefix = node_id.split("-")[0] if "-" in node_id else node_id + return PREFIX_TO_TYPE.get(prefix, "feature") + def atomic_claim(self, node_id: str, agent: str | None = None) -> bool: """ Atomically claim a work item using SQL compare-and-swap. diff --git a/src/python/htmlgraph/converter.py b/src/python/htmlgraph/converter.py index b338e25c..3851f985 100644 --- a/src/python/htmlgraph/converter.py +++ b/src/python/htmlgraph/converter.py @@ -73,6 +73,9 @@ def html_to_node(filepath: Path | str) -> Node: description=s["description"], completed=s.get("completed", False), agent=s.get("agent"), + timestamp=s.get("timestamp"), + step_id=s.get("step_id"), + depends_on=s.get("depends_on", []), ) for s in data.get("steps", []) ] diff --git a/src/python/htmlgraph/db/ddl.py b/src/python/htmlgraph/db/ddl.py index 42c35b4f..3227a9c4 100644 --- a/src/python/htmlgraph/db/ddl.py +++ b/src/python/htmlgraph/db/ddl.py @@ -57,6 +57,7 @@ def create_all_tables(cursor: sqlite3.Cursor) -> None: model TEXT, claude_task_id TEXT, source TEXT DEFAULT 'hook', + step_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ON UPDATE CASCADE, @@ -276,6 +277,7 @@ def create_all_indexes(cursor: sqlite3.Cursor) -> None: "CREATE INDEX IF NOT EXISTS idx_agent_events_session_tool ON agent_events(session_id, tool_name)", "CREATE INDEX IF NOT EXISTS idx_agent_events_timestamp ON agent_events(timestamp DESC)", "CREATE INDEX IF NOT EXISTS idx_agent_events_claude_task_id ON agent_events(claude_task_id)", + "CREATE INDEX IF NOT EXISTS idx_agent_events_step_id ON agent_events(step_id)", # features indexes "CREATE INDEX IF NOT EXISTS idx_features_status_priority ON features(status, priority DESC, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_features_track_priority ON features(track_id, priority DESC, created_at DESC)", @@ -354,6 +356,7 @@ def migrate_agent_events(cursor: sqlite3.Cursor) -> None: ("claude_task_id", "TEXT"), ("tool_input", "JSON"), ("source", "TEXT"), + ("step_id", "TEXT"), ] for col_name, col_type in migrations: diff --git a/src/python/htmlgraph/db/edge_sync.py b/src/python/htmlgraph/db/edge_sync.py new file mode 100644 index 00000000..3a3d6d1f --- /dev/null +++ b/src/python/htmlgraph/db/edge_sync.py @@ -0,0 +1,148 @@ +""" +HTML <-> SQLite edge synchronization. + +Provides idempotent sync between HTML file edges (in