htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bounded path-finding and cycle detection for HtmlGraph.
|
|
3
|
+
|
|
4
|
+
Provides safe, deterministic graph traversal algorithms with built-in
|
|
5
|
+
cycle avoidance and depth bounds. Replaces timeout-based safety guards
|
|
6
|
+
with structural guarantees:
|
|
7
|
+
|
|
8
|
+
- BFS for shortest paths: O(V+E) guaranteed
|
|
9
|
+
- DFS with per-path visited tracking for bounded enumeration
|
|
10
|
+
- Cycle detection with configurable depth limits
|
|
11
|
+
|
|
12
|
+
All algorithms terminate deterministically via depth bounds,
|
|
13
|
+
never requiring timeouts.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import deque
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from htmlgraph.edge_index import EdgeRef
|
|
24
|
+
from htmlgraph.graph import HtmlGraph
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PathResult:
|
|
29
|
+
"""
|
|
30
|
+
Result of a path-finding operation.
|
|
31
|
+
|
|
32
|
+
Represents an ordered sequence of nodes connected by edges,
|
|
33
|
+
forming a path through the graph.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
nodes: Ordered list of node IDs in the path (source first, target last).
|
|
37
|
+
edges: List of EdgeRef objects for each edge traversed.
|
|
38
|
+
length: Number of edges in the path (len(nodes) - 1).
|
|
39
|
+
relationship_types: Distinct edge relationship types used in this path.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
nodes: list[str]
|
|
43
|
+
edges: list[EdgeRef]
|
|
44
|
+
length: int
|
|
45
|
+
relationship_types: list[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CycleResult:
|
|
50
|
+
"""
|
|
51
|
+
Result of a cycle detection operation.
|
|
52
|
+
|
|
53
|
+
Represents a cycle found in the graph, identified by the sequence
|
|
54
|
+
of nodes that form a closed loop.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
cycle: Node IDs forming the cycle. The first and last element
|
|
58
|
+
are the same node, closing the loop.
|
|
59
|
+
length: Number of edges in the cycle.
|
|
60
|
+
edge_types: Distinct relationship types in the cycle.
|
|
61
|
+
involves_node: The node that was queried or that participates
|
|
62
|
+
in this cycle.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
cycle: list[str]
|
|
66
|
+
length: int
|
|
67
|
+
edge_types: list[str]
|
|
68
|
+
involves_node: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class BoundedPathFinder:
|
|
73
|
+
"""
|
|
74
|
+
Safe, bounded graph traversal with cycle avoidance.
|
|
75
|
+
|
|
76
|
+
Provides deterministic path-finding and cycle detection algorithms
|
|
77
|
+
that terminate based on depth bounds rather than timeouts.
|
|
78
|
+
|
|
79
|
+
All methods use the graph's EdgeIndex for efficient O(1) neighbor
|
|
80
|
+
lookups and support optional edge-type filtering.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> from htmlgraph.graph import HtmlGraph
|
|
84
|
+
>>> graph = HtmlGraph("features/", auto_load=True)
|
|
85
|
+
>>> finder = BoundedPathFinder(graph)
|
|
86
|
+
>>> path = finder.any_shortest("feat-001", "feat-010")
|
|
87
|
+
>>> if path:
|
|
88
|
+
... print(f"Shortest path: {' -> '.join(path.nodes)}")
|
|
89
|
+
>>> cycles = finder.find_cycles("feat-001")
|
|
90
|
+
>>> for c in cycles:
|
|
91
|
+
... print(f"Cycle of length {c.length}: {c.cycle}")
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
graph: HtmlGraph
|
|
95
|
+
max_depth: int = 20
|
|
96
|
+
|
|
97
|
+
# Internal caches, not part of __init__ signature
|
|
98
|
+
_adjacency_cache: dict[str, dict[str, list[_NeighborInfo]]] = field(
|
|
99
|
+
default_factory=dict, init=False, repr=False
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _get_neighbors(
|
|
103
|
+
self,
|
|
104
|
+
node_id: str,
|
|
105
|
+
edge_types: list[str] | None,
|
|
106
|
+
direction: str = "outgoing",
|
|
107
|
+
) -> list[_NeighborInfo]:
|
|
108
|
+
"""
|
|
109
|
+
Get neighbors of a node with edge metadata, using the EdgeIndex.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
node_id: The node to get neighbors for.
|
|
113
|
+
edge_types: If provided, only follow edges with these relationship types.
|
|
114
|
+
direction: "outgoing" follows edges from node_id, "incoming" follows
|
|
115
|
+
edges pointing to node_id.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of _NeighborInfo with neighbor_id and the EdgeRef.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
results: list[_NeighborInfo] = []
|
|
122
|
+
if direction == "outgoing":
|
|
123
|
+
refs = self.graph._edge_index.get_outgoing(node_id)
|
|
124
|
+
for ref in refs:
|
|
125
|
+
if edge_types is None or ref.relationship in edge_types:
|
|
126
|
+
results.append(_NeighborInfo(ref.target_id, ref))
|
|
127
|
+
else: # incoming
|
|
128
|
+
refs = self.graph._edge_index.get_incoming(node_id)
|
|
129
|
+
for ref in refs:
|
|
130
|
+
if edge_types is None or ref.relationship in edge_types:
|
|
131
|
+
results.append(_NeighborInfo(ref.source_id, ref))
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Public API
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def any_shortest(
|
|
139
|
+
self,
|
|
140
|
+
from_id: str,
|
|
141
|
+
to_id: str,
|
|
142
|
+
edge_types: list[str] | None = None,
|
|
143
|
+
) -> PathResult | None:
|
|
144
|
+
"""
|
|
145
|
+
Find ANY shortest path between two nodes using BFS.
|
|
146
|
+
|
|
147
|
+
Guaranteed O(V+E) time complexity with built-in cycle avoidance
|
|
148
|
+
via the BFS visited set.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
from_id: Source node ID.
|
|
152
|
+
to_id: Target node ID.
|
|
153
|
+
edge_types: If provided, only traverse edges with these relationship types.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A PathResult for one shortest path, or None if no path exists.
|
|
157
|
+
"""
|
|
158
|
+
if from_id not in self.graph._nodes or to_id not in self.graph._nodes:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if from_id == to_id:
|
|
162
|
+
return PathResult(
|
|
163
|
+
nodes=[from_id], edges=[], length=0, relationship_types=[]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# BFS: queue entries are (current_node, path_of_nodes, path_of_edges)
|
|
167
|
+
queue: deque[tuple[str, list[str], list[EdgeRef]]] = deque()
|
|
168
|
+
queue.append((from_id, [from_id], []))
|
|
169
|
+
visited: set[str] = {from_id}
|
|
170
|
+
|
|
171
|
+
while queue:
|
|
172
|
+
current, path_nodes, path_edges = queue.popleft()
|
|
173
|
+
|
|
174
|
+
for info in self._get_neighbors(current, edge_types, "outgoing"):
|
|
175
|
+
neighbor = info.neighbor_id
|
|
176
|
+
edge_ref = info.edge_ref
|
|
177
|
+
|
|
178
|
+
new_nodes = path_nodes + [neighbor]
|
|
179
|
+
new_edges = path_edges + [edge_ref]
|
|
180
|
+
|
|
181
|
+
if neighbor == to_id:
|
|
182
|
+
rel_types = sorted(set(e.relationship for e in new_edges))
|
|
183
|
+
return PathResult(
|
|
184
|
+
nodes=new_nodes,
|
|
185
|
+
edges=new_edges,
|
|
186
|
+
length=len(new_edges),
|
|
187
|
+
relationship_types=rel_types,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if neighbor not in visited and neighbor in self.graph._nodes:
|
|
191
|
+
visited.add(neighbor)
|
|
192
|
+
queue.append((neighbor, new_nodes, new_edges))
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def all_shortest(
|
|
197
|
+
self,
|
|
198
|
+
from_id: str,
|
|
199
|
+
to_id: str,
|
|
200
|
+
edge_types: list[str] | None = None,
|
|
201
|
+
) -> list[PathResult]:
|
|
202
|
+
"""
|
|
203
|
+
Find ALL shortest paths (same minimum length) between two nodes.
|
|
204
|
+
|
|
205
|
+
Uses BFS to determine the shortest distance, then enumerates all
|
|
206
|
+
paths of exactly that length. The BFS phase is O(V+E); the
|
|
207
|
+
enumeration phase explores only paths within the shortest distance
|
|
208
|
+
bound.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
from_id: Source node ID.
|
|
212
|
+
to_id: Target node ID.
|
|
213
|
+
edge_types: If provided, only traverse edges with these relationship types.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
List of PathResult objects, all having the same minimum length.
|
|
217
|
+
Empty list if no path exists.
|
|
218
|
+
"""
|
|
219
|
+
if from_id not in self.graph._nodes or to_id not in self.graph._nodes:
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
if from_id == to_id:
|
|
223
|
+
return [
|
|
224
|
+
PathResult(nodes=[from_id], edges=[], length=0, relationship_types=[])
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
# Phase 1: BFS to find shortest distance and predecessor map.
|
|
228
|
+
# For each node, record ALL predecessors at the shortest distance.
|
|
229
|
+
dist: dict[str, int] = {from_id: 0}
|
|
230
|
+
# predecessors maps node -> list of (predecessor_node, edge_ref)
|
|
231
|
+
predecessors: dict[str, list[tuple[str, EdgeRef]]] = {}
|
|
232
|
+
queue: deque[str] = deque([from_id])
|
|
233
|
+
|
|
234
|
+
while queue:
|
|
235
|
+
current = queue.popleft()
|
|
236
|
+
current_dist = dist[current]
|
|
237
|
+
|
|
238
|
+
for info in self._get_neighbors(current, edge_types, "outgoing"):
|
|
239
|
+
neighbor = info.neighbor_id
|
|
240
|
+
edge_ref = info.edge_ref
|
|
241
|
+
|
|
242
|
+
if neighbor not in self.graph._nodes:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
new_dist = current_dist + 1
|
|
246
|
+
|
|
247
|
+
if neighbor not in dist:
|
|
248
|
+
# First time reaching this node
|
|
249
|
+
dist[neighbor] = new_dist
|
|
250
|
+
predecessors[neighbor] = [(current, edge_ref)]
|
|
251
|
+
queue.append(neighbor)
|
|
252
|
+
elif dist[neighbor] == new_dist:
|
|
253
|
+
# Same shortest distance, add alternative predecessor
|
|
254
|
+
predecessors[neighbor].append((current, edge_ref))
|
|
255
|
+
|
|
256
|
+
if to_id not in dist:
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
# Phase 2: Backtrack from to_id to from_id using predecessors.
|
|
260
|
+
results: list[PathResult] = []
|
|
261
|
+
|
|
262
|
+
def _backtrack(
|
|
263
|
+
node: str,
|
|
264
|
+
path_nodes: list[str],
|
|
265
|
+
path_edges: list[EdgeRef],
|
|
266
|
+
) -> None:
|
|
267
|
+
if node == from_id:
|
|
268
|
+
# Reverse to get source-to-target order
|
|
269
|
+
final_nodes = list(reversed(path_nodes))
|
|
270
|
+
final_edges = list(reversed(path_edges))
|
|
271
|
+
rel_types = sorted(set(e.relationship for e in final_edges))
|
|
272
|
+
results.append(
|
|
273
|
+
PathResult(
|
|
274
|
+
nodes=final_nodes,
|
|
275
|
+
edges=final_edges,
|
|
276
|
+
length=len(final_edges),
|
|
277
|
+
relationship_types=rel_types,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
for pred_node, edge_ref in predecessors.get(node, []):
|
|
283
|
+
path_nodes.append(pred_node)
|
|
284
|
+
path_edges.append(edge_ref)
|
|
285
|
+
_backtrack(pred_node, path_nodes, path_edges)
|
|
286
|
+
path_nodes.pop()
|
|
287
|
+
path_edges.pop()
|
|
288
|
+
|
|
289
|
+
_backtrack(to_id, [to_id], [])
|
|
290
|
+
return results
|
|
291
|
+
|
|
292
|
+
def bounded_paths(
|
|
293
|
+
self,
|
|
294
|
+
from_id: str,
|
|
295
|
+
to_id: str,
|
|
296
|
+
max_depth: int | None = None,
|
|
297
|
+
max_results: int = 100,
|
|
298
|
+
edge_types: list[str] | None = None,
|
|
299
|
+
) -> list[PathResult]:
|
|
300
|
+
"""
|
|
301
|
+
Find paths up to max_depth with built-in cycle avoidance per path.
|
|
302
|
+
|
|
303
|
+
Replaces all_paths() with a deterministic depth bound instead of
|
|
304
|
+
a timeout. Each path independently tracks visited nodes to allow
|
|
305
|
+
different paths to share intermediate nodes while preventing
|
|
306
|
+
cycles within any single path.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
from_id: Source node ID.
|
|
310
|
+
to_id: Target node ID.
|
|
311
|
+
max_depth: Maximum path length in edges. Defaults to self.max_depth.
|
|
312
|
+
max_results: Maximum number of paths to return (default 100).
|
|
313
|
+
edge_types: If provided, only traverse edges with these relationship types.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of PathResult objects, up to max_results.
|
|
317
|
+
"""
|
|
318
|
+
depth_limit = max_depth if max_depth is not None else self.max_depth
|
|
319
|
+
|
|
320
|
+
if from_id not in self.graph._nodes or to_id not in self.graph._nodes:
|
|
321
|
+
return []
|
|
322
|
+
|
|
323
|
+
if from_id == to_id:
|
|
324
|
+
return [
|
|
325
|
+
PathResult(nodes=[from_id], edges=[], length=0, relationship_types=[])
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
results: list[PathResult] = []
|
|
329
|
+
|
|
330
|
+
def _dfs(
|
|
331
|
+
current: str,
|
|
332
|
+
path_nodes: list[str],
|
|
333
|
+
path_edges: list[EdgeRef],
|
|
334
|
+
visited: set[str],
|
|
335
|
+
) -> None:
|
|
336
|
+
if len(results) >= max_results:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
if len(path_edges) > depth_limit:
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
if current == to_id:
|
|
343
|
+
rel_types = sorted(set(e.relationship for e in path_edges))
|
|
344
|
+
results.append(
|
|
345
|
+
PathResult(
|
|
346
|
+
nodes=list(path_nodes),
|
|
347
|
+
edges=list(path_edges),
|
|
348
|
+
length=len(path_edges),
|
|
349
|
+
relationship_types=rel_types,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
# Don't go deeper if we're at the depth limit
|
|
355
|
+
if len(path_edges) >= depth_limit:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
for info in self._get_neighbors(current, edge_types, "outgoing"):
|
|
359
|
+
neighbor = info.neighbor_id
|
|
360
|
+
if neighbor not in visited and neighbor in self.graph._nodes:
|
|
361
|
+
visited.add(neighbor)
|
|
362
|
+
path_nodes.append(neighbor)
|
|
363
|
+
path_edges.append(info.edge_ref)
|
|
364
|
+
_dfs(neighbor, path_nodes, path_edges, visited)
|
|
365
|
+
path_edges.pop()
|
|
366
|
+
path_nodes.pop()
|
|
367
|
+
visited.remove(neighbor)
|
|
368
|
+
|
|
369
|
+
_dfs(from_id, [from_id], [], {from_id})
|
|
370
|
+
return results
|
|
371
|
+
|
|
372
|
+
def find_cycles(
|
|
373
|
+
self,
|
|
374
|
+
node_id: str | None = None,
|
|
375
|
+
edge_types: list[str] | None = None,
|
|
376
|
+
max_cycle_length: int = 10,
|
|
377
|
+
) -> list[CycleResult]:
|
|
378
|
+
"""
|
|
379
|
+
Detect cycles in the graph.
|
|
380
|
+
|
|
381
|
+
If node_id is provided, finds cycles that include that specific node.
|
|
382
|
+
Otherwise, finds all cycles up to max_cycle_length in the entire graph.
|
|
383
|
+
|
|
384
|
+
Uses DFS with depth bounding for deterministic termination.
|
|
385
|
+
Inspired by SQL/PGQ ownership-cycle detection patterns.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
node_id: If provided, only find cycles involving this node.
|
|
389
|
+
edge_types: If provided, only follow edges with these relationship types.
|
|
390
|
+
max_cycle_length: Maximum cycle length to search for (default 10).
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
List of CycleResult objects describing each cycle found.
|
|
394
|
+
"""
|
|
395
|
+
if node_id is not None:
|
|
396
|
+
return self._find_cycles_for_node(node_id, edge_types, max_cycle_length)
|
|
397
|
+
|
|
398
|
+
# Find cycles for all nodes
|
|
399
|
+
all_cycles: list[CycleResult] = []
|
|
400
|
+
seen_cycles: set[tuple[str, ...]] = set()
|
|
401
|
+
|
|
402
|
+
for nid in self.graph._nodes:
|
|
403
|
+
for cycle_result in self._find_cycles_for_node(
|
|
404
|
+
nid, edge_types, max_cycle_length
|
|
405
|
+
):
|
|
406
|
+
# Normalize cycle for deduplication: rotate so smallest ID is first
|
|
407
|
+
cycle_nodes = cycle_result.cycle[:-1] # Remove closing duplicate
|
|
408
|
+
if not cycle_nodes:
|
|
409
|
+
continue
|
|
410
|
+
min_idx = cycle_nodes.index(min(cycle_nodes))
|
|
411
|
+
normalized = tuple(cycle_nodes[min_idx:] + cycle_nodes[:min_idx])
|
|
412
|
+
|
|
413
|
+
if normalized not in seen_cycles:
|
|
414
|
+
seen_cycles.add(normalized)
|
|
415
|
+
all_cycles.append(cycle_result)
|
|
416
|
+
|
|
417
|
+
return all_cycles
|
|
418
|
+
|
|
419
|
+
def _find_cycles_for_node(
|
|
420
|
+
self,
|
|
421
|
+
node_id: str,
|
|
422
|
+
edge_types: list[str] | None,
|
|
423
|
+
max_cycle_length: int,
|
|
424
|
+
) -> list[CycleResult]:
|
|
425
|
+
"""
|
|
426
|
+
Find all cycles involving a specific node, up to max_cycle_length.
|
|
427
|
+
|
|
428
|
+
Uses iterative DFS from node_id looking for paths that return to it.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
node_id: The node to find cycles for.
|
|
432
|
+
edge_types: Optional edge type filter.
|
|
433
|
+
max_cycle_length: Maximum edges in a cycle.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
List of CycleResult objects for cycles involving node_id.
|
|
437
|
+
"""
|
|
438
|
+
if node_id not in self.graph._nodes:
|
|
439
|
+
return []
|
|
440
|
+
|
|
441
|
+
results: list[CycleResult] = []
|
|
442
|
+
|
|
443
|
+
def _dfs(
|
|
444
|
+
current: str,
|
|
445
|
+
path: list[str],
|
|
446
|
+
path_edges: list[EdgeRef],
|
|
447
|
+
visited: set[str],
|
|
448
|
+
) -> None:
|
|
449
|
+
if len(path_edges) > max_cycle_length:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
for info in self._get_neighbors(current, edge_types, "outgoing"):
|
|
453
|
+
neighbor = info.neighbor_id
|
|
454
|
+
candidate_length = len(path_edges) + 1
|
|
455
|
+
|
|
456
|
+
if neighbor == node_id:
|
|
457
|
+
# Found a cycle back to start (includes self-loops)
|
|
458
|
+
if candidate_length <= max_cycle_length:
|
|
459
|
+
cycle_path = path + [node_id]
|
|
460
|
+
all_edges = path_edges + [info.edge_ref]
|
|
461
|
+
edge_type_set = sorted(set(e.relationship for e in all_edges))
|
|
462
|
+
results.append(
|
|
463
|
+
CycleResult(
|
|
464
|
+
cycle=cycle_path,
|
|
465
|
+
length=len(all_edges),
|
|
466
|
+
edge_types=edge_type_set,
|
|
467
|
+
involves_node=node_id,
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
elif (
|
|
471
|
+
neighbor not in visited
|
|
472
|
+
and neighbor in self.graph._nodes
|
|
473
|
+
and candidate_length < max_cycle_length
|
|
474
|
+
):
|
|
475
|
+
visited.add(neighbor)
|
|
476
|
+
path.append(neighbor)
|
|
477
|
+
path_edges.append(info.edge_ref)
|
|
478
|
+
_dfs(neighbor, path, path_edges, visited)
|
|
479
|
+
path_edges.pop()
|
|
480
|
+
path.pop()
|
|
481
|
+
visited.remove(neighbor)
|
|
482
|
+
|
|
483
|
+
_dfs(node_id, [node_id], [], {node_id})
|
|
484
|
+
return results
|
|
485
|
+
|
|
486
|
+
def reachable_set(
|
|
487
|
+
self,
|
|
488
|
+
from_id: str,
|
|
489
|
+
edge_types: list[str] | None = None,
|
|
490
|
+
direction: str = "outgoing",
|
|
491
|
+
max_depth: int | None = None,
|
|
492
|
+
) -> set[str]:
|
|
493
|
+
"""
|
|
494
|
+
Find all nodes reachable from a starting node within a depth bound.
|
|
495
|
+
|
|
496
|
+
Uses BFS for level-by-level exploration. Useful for transitive
|
|
497
|
+
dependency analysis with limits.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
from_id: Starting node ID.
|
|
501
|
+
edge_types: If provided, only follow edges with these relationship types.
|
|
502
|
+
direction: "outgoing" follows edges from source, "incoming" follows
|
|
503
|
+
edges pointing to source.
|
|
504
|
+
max_depth: Maximum traversal depth. Defaults to self.max_depth.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Set of reachable node IDs (does not include from_id itself).
|
|
508
|
+
"""
|
|
509
|
+
depth_limit = max_depth if max_depth is not None else self.max_depth
|
|
510
|
+
|
|
511
|
+
if from_id not in self.graph._nodes:
|
|
512
|
+
return set()
|
|
513
|
+
|
|
514
|
+
reachable: set[str] = set()
|
|
515
|
+
visited: set[str] = {from_id}
|
|
516
|
+
queue: deque[tuple[str, int]] = deque([(from_id, 0)])
|
|
517
|
+
|
|
518
|
+
while queue:
|
|
519
|
+
current, depth = queue.popleft()
|
|
520
|
+
|
|
521
|
+
if depth >= depth_limit:
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
for info in self._get_neighbors(current, edge_types, direction):
|
|
525
|
+
neighbor = info.neighbor_id
|
|
526
|
+
if neighbor not in visited and neighbor in self.graph._nodes:
|
|
527
|
+
visited.add(neighbor)
|
|
528
|
+
reachable.add(neighbor)
|
|
529
|
+
queue.append((neighbor, depth + 1))
|
|
530
|
+
|
|
531
|
+
return reachable
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@dataclass
|
|
535
|
+
class _NeighborInfo:
|
|
536
|
+
"""Internal helper pairing a neighbor ID with its edge reference."""
|
|
537
|
+
|
|
538
|
+
neighbor_id: str
|
|
539
|
+
edge_ref: EdgeRef
|
htmlgraph/builders/base.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
"""
|
|
2
4
|
Base builder class for fluent node creation.
|
|
3
5
|
|
|
4
6
|
Provides common builder patterns shared across all node types.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
|
-
from __future__ import annotations
|
|
8
9
|
|
|
9
10
|
from datetime import datetime
|
|
10
11
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
@@ -161,6 +162,9 @@ class BaseBuilder(Generic[BuilderT]):
|
|
|
161
162
|
|
|
162
163
|
Returns:
|
|
163
164
|
Created Node instance
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: If node type requires track_id but none is set
|
|
164
168
|
"""
|
|
165
169
|
# Generate collision-resistant ID if not provided
|
|
166
170
|
if "id" not in self._data:
|
|
@@ -169,6 +173,33 @@ class BaseBuilder(Generic[BuilderT]):
|
|
|
169
173
|
title=self._data.get("title", ""),
|
|
170
174
|
)
|
|
171
175
|
|
|
176
|
+
# Validate track_id requirement for features
|
|
177
|
+
node_type = self._data.get("type", self.node_type)
|
|
178
|
+
if node_type == "feature" and not self._data.get("track_id"):
|
|
179
|
+
# Get available tracks for helpful error message
|
|
180
|
+
try:
|
|
181
|
+
tracks = self._sdk.tracks.all()
|
|
182
|
+
track_options = "\n".join(
|
|
183
|
+
[f" - {track.id}: {track.title}" for track in tracks[:10]]
|
|
184
|
+
)
|
|
185
|
+
if len(tracks) > 10:
|
|
186
|
+
track_options += f"\n ... and {len(tracks) - 10} more tracks"
|
|
187
|
+
|
|
188
|
+
error_msg = (
|
|
189
|
+
f"Feature '{self._data.get('title', 'Unknown')}' requires a track linkage.\n\n"
|
|
190
|
+
f"Use: .set_track('track_id') to link to a track before saving.\n\n"
|
|
191
|
+
f"Available tracks:\n{track_options or ' (no tracks found)'}\n\n"
|
|
192
|
+
f"Create a track first: sdk.tracks.create('Track Title')"
|
|
193
|
+
)
|
|
194
|
+
except Exception:
|
|
195
|
+
# Fallback error message if we can't fetch tracks
|
|
196
|
+
error_msg = (
|
|
197
|
+
f"Feature '{self._data.get('title', 'Unknown')}' requires a track linkage.\n"
|
|
198
|
+
f"Use: .set_track('track_id') to link to a track before saving."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
raise ValueError(error_msg)
|
|
202
|
+
|
|
172
203
|
# Import Node here to avoid circular imports
|
|
173
204
|
from htmlgraph.models import Node
|
|
174
205
|
|
|
@@ -191,7 +222,31 @@ class BaseBuilder(Generic[BuilderT]):
|
|
|
191
222
|
graph = HtmlGraph(graph_path, auto_load=False)
|
|
192
223
|
graph.add(node)
|
|
193
224
|
|
|
194
|
-
# Log creation event
|
|
225
|
+
# Log creation event to SQLite for dashboard observability
|
|
226
|
+
try:
|
|
227
|
+
action_type = self._data.get("type", self.node_type)
|
|
228
|
+
self._sdk._log_event(
|
|
229
|
+
event_type="tool_call",
|
|
230
|
+
tool_name="SDK.create",
|
|
231
|
+
input_summary=f"Create {action_type}: {self._data.get('title', 'Untitled')}",
|
|
232
|
+
output_summary=f"Created {collection_name}/{node.id}",
|
|
233
|
+
context={
|
|
234
|
+
"collection": collection_name,
|
|
235
|
+
"node_id": node.id,
|
|
236
|
+
"node_type": action_type,
|
|
237
|
+
"title": node.title,
|
|
238
|
+
"status": self._data.get("status", "todo"),
|
|
239
|
+
"priority": self._data.get("priority", "medium"),
|
|
240
|
+
},
|
|
241
|
+
cost_tokens=50,
|
|
242
|
+
)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
# Never break save because of logging
|
|
245
|
+
import logging
|
|
246
|
+
|
|
247
|
+
logging.debug(f"Event logging failed: {e}")
|
|
248
|
+
|
|
249
|
+
# Also log via SessionManager for backward compatibility
|
|
195
250
|
if hasattr(self._sdk, "session_manager") and self._sdk.agent:
|
|
196
251
|
try:
|
|
197
252
|
self._sdk.session_manager._maybe_log_work_item_action(
|
htmlgraph/builders/bug.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
"""
|
|
2
4
|
Bug builder for creating bug report nodes.
|
|
3
5
|
|
|
@@ -5,12 +7,11 @@ Extends BaseBuilder with bug-specific methods like
|
|
|
5
7
|
severity and reproduction steps.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
|
-
from __future__ import annotations
|
|
9
10
|
|
|
10
|
-
from typing import TYPE_CHECKING
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
11
12
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
|
-
|
|
14
|
+
from htmlgraph.sdk import SDK
|
|
14
15
|
|
|
15
16
|
from htmlgraph.builders.base import BaseBuilder
|
|
16
17
|
|
|
@@ -37,6 +38,21 @@ class BugBuilder(BaseBuilder["BugBuilder"]):
|
|
|
37
38
|
|
|
38
39
|
node_type = "bug"
|
|
39
40
|
|
|
41
|
+
def __init__(self, sdk: SDK, title: str, **kwargs: Any):
|
|
42
|
+
"""Initialize bug builder with agent attribution."""
|
|
43
|
+
super().__init__(sdk, title, **kwargs)
|
|
44
|
+
# Auto-assign agent from SDK for work tracking
|
|
45
|
+
if sdk._agent_id:
|
|
46
|
+
self._data["agent_assigned"] = sdk._agent_id
|
|
47
|
+
elif "agent_assigned" not in self._data:
|
|
48
|
+
# Log warning if agent not assigned (defensive check)
|
|
49
|
+
import logging
|
|
50
|
+
|
|
51
|
+
logging.warning(
|
|
52
|
+
f"Creating bug '{self._data.get('title', 'Unknown')}' without agent attribution. "
|
|
53
|
+
"Pass agent='name' to SDK() initialization."
|
|
54
|
+
)
|
|
55
|
+
|
|
40
56
|
def set_severity(self, severity: str) -> BugBuilder:
|
|
41
57
|
"""
|
|
42
58
|
Set bug severity level.
|