shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (161) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +48 -45
  7. shotgun/agents/config/provider.py +44 -29
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +41 -0
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/cli/clear.py +2 -2
  49. shotgun/cli/codebase/commands.py +181 -65
  50. shotgun/cli/compact.py +2 -2
  51. shotgun/cli/context.py +2 -2
  52. shotgun/cli/run.py +90 -0
  53. shotgun/cli/spec/backup.py +2 -1
  54. shotgun/cli/spec/commands.py +2 -0
  55. shotgun/cli/spec/models.py +18 -0
  56. shotgun/cli/spec/pull_service.py +122 -68
  57. shotgun/codebase/__init__.py +2 -0
  58. shotgun/codebase/benchmarks/__init__.py +35 -0
  59. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  60. shotgun/codebase/benchmarks/exporters.py +119 -0
  61. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  62. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  63. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  64. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  65. shotgun/codebase/benchmarks/models.py +129 -0
  66. shotgun/codebase/core/__init__.py +4 -0
  67. shotgun/codebase/core/call_resolution.py +91 -0
  68. shotgun/codebase/core/change_detector.py +11 -6
  69. shotgun/codebase/core/errors.py +159 -0
  70. shotgun/codebase/core/extractors/__init__.py +23 -0
  71. shotgun/codebase/core/extractors/base.py +138 -0
  72. shotgun/codebase/core/extractors/factory.py +63 -0
  73. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  74. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  75. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  76. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  77. shotgun/codebase/core/extractors/protocol.py +109 -0
  78. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  79. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  80. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  81. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  82. shotgun/codebase/core/extractors/types.py +15 -0
  83. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  84. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  85. shotgun/codebase/core/gitignore.py +252 -0
  86. shotgun/codebase/core/ingestor.py +644 -354
  87. shotgun/codebase/core/kuzu_compat.py +119 -0
  88. shotgun/codebase/core/language_config.py +239 -0
  89. shotgun/codebase/core/manager.py +256 -46
  90. shotgun/codebase/core/metrics_collector.py +310 -0
  91. shotgun/codebase/core/metrics_types.py +347 -0
  92. shotgun/codebase/core/parallel_executor.py +424 -0
  93. shotgun/codebase/core/work_distributor.py +254 -0
  94. shotgun/codebase/core/worker.py +768 -0
  95. shotgun/codebase/indexing_state.py +86 -0
  96. shotgun/codebase/models.py +94 -0
  97. shotgun/codebase/service.py +13 -0
  98. shotgun/exceptions.py +1 -1
  99. shotgun/main.py +2 -10
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +43 -1
  107. shotgun/prompts/agents/research.j2 +75 -20
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +94 -4
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -15
  112. shotgun/prompts/agents/tasks.j2 +77 -23
  113. shotgun/settings.py +44 -0
  114. shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
  115. shotgun/tui/app.py +90 -23
  116. shotgun/tui/commands/__init__.py +9 -1
  117. shotgun/tui/components/attachment_bar.py +87 -0
  118. shotgun/tui/components/mode_indicator.py +120 -25
  119. shotgun/tui/components/prompt_input.py +23 -28
  120. shotgun/tui/components/status_bar.py +5 -4
  121. shotgun/tui/dependencies.py +58 -8
  122. shotgun/tui/protocols.py +37 -0
  123. shotgun/tui/screens/chat/chat.tcss +24 -1
  124. shotgun/tui/screens/chat/chat_screen.py +1374 -211
  125. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  126. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  128. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  129. shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
  130. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  131. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  132. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  133. shotgun/tui/screens/chat_screen/messages.py +219 -0
  134. shotgun/tui/screens/database_locked_dialog.py +219 -0
  135. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  136. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  137. shotgun/tui/screens/model_picker.py +14 -9
  138. shotgun/tui/screens/models.py +11 -0
  139. shotgun/tui/screens/shotgun_auth.py +50 -0
  140. shotgun/tui/screens/spec_pull.py +2 -0
  141. shotgun/tui/state/processing_state.py +19 -0
  142. shotgun/tui/utils/mode_progress.py +20 -86
  143. shotgun/tui/widgets/__init__.py +2 -1
  144. shotgun/tui/widgets/approval_widget.py +152 -0
  145. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  146. shotgun/tui/widgets/plan_panel.py +129 -0
  147. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  148. shotgun/tui/widgets/widget_coordinator.py +18 -0
  149. shotgun/utils/file_system_utils.py +4 -1
  150. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
  151. shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
  152. shotgun/cli/export.py +0 -81
  153. shotgun/cli/plan.py +0 -73
  154. shotgun/cli/research.py +0 -93
  155. shotgun/cli/specify.py +0 -70
  156. shotgun/cli/tasks.py +0 -78
  157. shotgun/tui/screens/onboarding.py +0 -580
  158. shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
  159. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
  160. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
  161. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,18 @@
1
1
  """Shared spec pull service for CLI and TUI."""
2
2
 
3
+ import time
3
4
  from collections.abc import Callable
4
5
  from dataclasses import dataclass
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
 
8
9
  from shotgun.logging_config import get_logger
10
+ from shotgun.posthog_telemetry import track_event
9
11
  from shotgun.shotgun_web.specs_client import SpecsClient
10
12
  from shotgun.shotgun_web.supabase_client import download_file_from_url
11
13
 
12
14
  from .backup import clear_shotgun_dir, create_backup
13
- from .models import SpecMeta
15
+ from .models import PullPhase, PullSource, SpecMeta
14
16
 
15
17
  logger = get_logger(__name__)
16
18
 
@@ -53,6 +55,7 @@ class SpecPullService:
53
55
  shotgun_dir: Path,
54
56
  on_progress: Callable[[PullProgress], None] | None = None,
55
57
  is_cancelled: Callable[[], bool] | None = None,
58
+ source: PullSource = PullSource.CLI,
56
59
  ) -> PullResult:
57
60
  """Pull a spec version to the local directory.
58
61
 
@@ -61,10 +64,14 @@ class SpecPullService:
61
64
  shotgun_dir: Target directory (typically .shotgun/)
62
65
  on_progress: Optional callback for progress updates
63
66
  is_cancelled: Optional callback to check if cancelled
67
+ source: Source of the pull request (CLI or TUI)
64
68
 
65
69
  Returns:
66
70
  PullResult with success status and details
67
71
  """
72
+ start_time = time.time()
73
+ current_phase: PullPhase = PullPhase.STARTING
74
+ track_event("spec_pull_started", {"source": source.value})
68
75
 
69
76
  def report(
70
77
  phase: str,
@@ -83,83 +90,130 @@ class SpecPullService:
83
90
  )
84
91
 
85
92
  def check_cancelled() -> None:
93
+ nonlocal current_phase
86
94
  if is_cancelled and is_cancelled():
95
+ track_event(
96
+ "spec_pull_cancelled",
97
+ {"source": source.value, "phase": current_phase.value},
98
+ )
87
99
  raise CancelledError()
88
100
 
89
- # Phase 1: Fetch version metadata
90
- report("Fetching version info...")
91
- check_cancelled()
101
+ try:
102
+ # Phase 1: Fetch version metadata
103
+ current_phase = PullPhase.FETCHING
104
+ report("Fetching version info...")
105
+ check_cancelled()
92
106
 
93
- response = await self._client.get_version_with_files(version_id)
94
- spec_name = response.spec_name
95
- files = response.files
107
+ response = await self._client.get_version_with_files(version_id)
108
+ spec_name = response.spec_name
109
+ files = response.files
110
+
111
+ if not files:
112
+ track_event(
113
+ "spec_pull_failed",
114
+ {
115
+ "source": source.value,
116
+ "error_type": "EmptyVersion",
117
+ "phase": current_phase.value,
118
+ },
119
+ )
120
+ return PullResult(
121
+ success=False,
122
+ spec_name=spec_name,
123
+ error="No files in this version.",
124
+ )
96
125
 
97
- if not files:
98
- return PullResult(
99
- success=False,
100
- spec_name=spec_name,
101
- error="No files in this version.",
102
- )
126
+ # Phase 2: Backup existing content
127
+ current_phase = PullPhase.BACKUP
128
+ backup_path: str | None = None
129
+ if shotgun_dir.exists():
130
+ report("Backing up existing files...")
131
+ check_cancelled()
132
+
133
+ backup_path = await create_backup(shotgun_dir)
134
+ if backup_path:
135
+ clear_shotgun_dir(shotgun_dir)
136
+
137
+ # Ensure directory exists
138
+ shotgun_dir.mkdir(parents=True, exist_ok=True)
139
+
140
+ # Phase 3: Download files
141
+ current_phase = PullPhase.DOWNLOADING
142
+ total_files = len(files)
143
+ total_bytes = 0
144
+ for idx, file_info in enumerate(files):
145
+ check_cancelled()
146
+
147
+ report(
148
+ f"Downloading files ({idx + 1}/{total_files})...",
149
+ file_index=idx,
150
+ total_files=total_files,
151
+ current_file=file_info.relative_path,
152
+ )
103
153
 
104
- # Phase 2: Backup existing content
105
- backup_path: str | None = None
106
- if shotgun_dir.exists():
107
- report("Backing up existing files...")
108
- check_cancelled()
154
+ if not file_info.download_url:
155
+ logger.warning(
156
+ "Skipping file without download URL: %s",
157
+ file_info.relative_path,
158
+ )
159
+ continue
109
160
 
110
- backup_path = await create_backup(shotgun_dir)
111
- if backup_path:
112
- clear_shotgun_dir(shotgun_dir)
161
+ content = await download_file_from_url(file_info.download_url)
162
+ total_bytes += file_info.size_bytes
113
163
 
114
- # Ensure directory exists
115
- shotgun_dir.mkdir(parents=True, exist_ok=True)
164
+ local_path = shotgun_dir / file_info.relative_path
165
+ local_path.parent.mkdir(parents=True, exist_ok=True)
166
+ local_path.write_bytes(content)
116
167
 
117
- # Phase 3: Download files
118
- total_files = len(files)
119
- for idx, file_info in enumerate(files):
168
+ # Phase 4: Write meta.json
169
+ current_phase = PullPhase.FINALIZING
170
+ report("Finalizing...")
120
171
  check_cancelled()
121
172
 
122
- report(
123
- f"Downloading files ({idx + 1}/{total_files})...",
124
- file_index=idx,
125
- total_files=total_files,
126
- current_file=file_info.relative_path,
173
+ meta = SpecMeta(
174
+ version_id=response.version.id,
175
+ spec_id=response.spec_id,
176
+ spec_name=response.spec_name,
177
+ workspace_id=response.workspace_id,
178
+ is_latest=response.version.is_latest,
179
+ pulled_at=datetime.now(timezone.utc),
180
+ backup_path=backup_path,
181
+ web_url=response.web_url,
182
+ )
183
+ meta_path = shotgun_dir / "meta.json"
184
+ meta_path.write_text(meta.model_dump_json(indent=2))
185
+
186
+ # Track successful completion
187
+ duration = time.time() - start_time
188
+ track_event(
189
+ "spec_pull_completed",
190
+ {
191
+ "source": source.value,
192
+ "file_count": total_files,
193
+ "total_bytes": total_bytes,
194
+ "duration_seconds": round(duration, 2),
195
+ "had_backup": backup_path is not None,
196
+ },
127
197
  )
128
198
 
129
- if not file_info.download_url:
130
- logger.warning(
131
- "Skipping file without download URL: %s",
132
- file_info.relative_path,
133
- )
134
- continue
135
-
136
- content = await download_file_from_url(file_info.download_url)
137
-
138
- local_path = shotgun_dir / file_info.relative_path
139
- local_path.parent.mkdir(parents=True, exist_ok=True)
140
- local_path.write_bytes(content)
141
-
142
- # Phase 4: Write meta.json
143
- report("Finalizing...")
144
- check_cancelled()
145
-
146
- meta = SpecMeta(
147
- version_id=response.version.id,
148
- spec_id=response.spec_id,
149
- spec_name=response.spec_name,
150
- workspace_id=response.workspace_id,
151
- is_latest=response.version.is_latest,
152
- pulled_at=datetime.now(timezone.utc),
153
- backup_path=backup_path,
154
- web_url=response.web_url,
155
- )
156
- meta_path = shotgun_dir / "meta.json"
157
- meta_path.write_text(meta.model_dump_json(indent=2))
158
-
159
- return PullResult(
160
- success=True,
161
- spec_name=spec_name,
162
- file_count=total_files,
163
- backup_path=backup_path,
164
- web_url=response.web_url,
165
- )
199
+ return PullResult(
200
+ success=True,
201
+ spec_name=spec_name,
202
+ file_count=total_files,
203
+ backup_path=backup_path,
204
+ web_url=response.web_url,
205
+ )
206
+
207
+ except CancelledError:
208
+ # Already tracked in check_cancelled()
209
+ raise
210
+ except Exception as e:
211
+ track_event(
212
+ "spec_pull_failed",
213
+ {
214
+ "source": source.value,
215
+ "error_type": type(e).__name__,
216
+ "phase": current_phase.value,
217
+ },
218
+ )
219
+ raise
@@ -1,5 +1,6 @@
1
1
  """Shotgun codebase analysis and graph management."""
2
2
 
3
+ from shotgun.codebase.indexing_state import IndexingState
3
4
  from shotgun.codebase.models import CodebaseGraph, GraphStatus, QueryResult, QueryType
4
5
  from shotgun.codebase.service import CodebaseService
5
6
 
@@ -7,6 +8,7 @@ __all__ = [
7
8
  "CodebaseService",
8
9
  "CodebaseGraph",
9
10
  "GraphStatus",
11
+ "IndexingState",
10
12
  "QueryResult",
11
13
  "QueryType",
12
14
  ]
@@ -0,0 +1,35 @@
1
+ """Benchmark system for codebase indexing performance analysis.
2
+
3
+ This package provides tools for running benchmarks and reporting metrics
4
+ for the codebase indexing pipeline.
5
+ """
6
+
7
+ from shotgun.codebase.benchmarks.benchmark_runner import BenchmarkRunner
8
+ from shotgun.codebase.benchmarks.exporters import MetricsExporter
9
+ from shotgun.codebase.benchmarks.formatters import (
10
+ JsonFormatter,
11
+ MarkdownFormatter,
12
+ MetricsDisplayOptions,
13
+ get_formatter,
14
+ )
15
+ from shotgun.codebase.benchmarks.models import (
16
+ BenchmarkConfig,
17
+ BenchmarkMode,
18
+ BenchmarkResults,
19
+ BenchmarkRun,
20
+ OutputFormat,
21
+ )
22
+
23
+ __all__ = [
24
+ "BenchmarkConfig",
25
+ "BenchmarkMode",
26
+ "BenchmarkResults",
27
+ "BenchmarkRun",
28
+ "BenchmarkRunner",
29
+ "JsonFormatter",
30
+ "MarkdownFormatter",
31
+ "MetricsDisplayOptions",
32
+ "MetricsExporter",
33
+ "OutputFormat",
34
+ "get_formatter",
35
+ ]
@@ -0,0 +1,309 @@
1
+ """Benchmark runner for codebase indexing performance analysis.
2
+
3
+ This module provides the BenchmarkRunner class for running benchmark iterations
4
+ and collecting performance statistics.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gc
10
+ import hashlib
11
+ import json
12
+ import shutil
13
+ import time
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from shotgun.codebase.benchmarks.models import (
19
+ BenchmarkConfig,
20
+ BenchmarkMode,
21
+ BenchmarkResults,
22
+ BenchmarkRun,
23
+ )
24
+ from shotgun.codebase.core import Ingestor, SimpleGraphBuilder
25
+ from shotgun.codebase.core.kuzu_compat import get_kuzu
26
+ from shotgun.codebase.core.metrics_collector import MetricsCollector
27
+ from shotgun.codebase.core.parser_loader import load_parsers
28
+ from shotgun.logging_config import get_logger
29
+ from shotgun.sdk.services import get_codebase_service
30
+ from shotgun.utils.file_system_utils import get_shotgun_home
31
+
32
+ logger = get_logger(__name__)
33
+
34
+
35
+ def _compute_graph_id(codebase_path: Path) -> str:
36
+ """Compute a unique graph ID from the codebase path.
37
+
38
+ Args:
39
+ codebase_path: Path to the codebase
40
+
41
+ Returns:
42
+ A 12-character hex string identifying this codebase
43
+ """
44
+ return hashlib.sha256(str(codebase_path).encode()).hexdigest()[:12]
45
+
46
+
47
+ class BenchmarkRunner:
48
+ """Runs benchmark iterations and collects statistics."""
49
+
50
+ def __init__(
51
+ self,
52
+ codebase_path: Path,
53
+ codebase_name: str,
54
+ iterations: int = 1,
55
+ warmup_iterations: int = 0,
56
+ parallel: bool = True,
57
+ worker_count: int | None = None,
58
+ collect_file_metrics: bool = True,
59
+ collect_worker_metrics: bool = True,
60
+ progress_callback: Callable[..., Any] | None = None,
61
+ ) -> None:
62
+ """Initialize benchmark runner.
63
+
64
+ Args:
65
+ codebase_path: Path to repository to benchmark
66
+ codebase_name: Human-readable name for the codebase
67
+ iterations: Number of measured benchmark runs
68
+ warmup_iterations: Number of warmup runs (not measured)
69
+ parallel: Whether to use parallel execution
70
+ worker_count: Number of workers (None = auto)
71
+ collect_file_metrics: Whether to collect per-file metrics
72
+ collect_worker_metrics: Whether to collect per-worker metrics
73
+ progress_callback: Optional callback for progress updates
74
+ """
75
+ self.codebase_path = codebase_path.resolve()
76
+ self.codebase_name = codebase_name
77
+ self.iterations = iterations
78
+ self.warmup_iterations = warmup_iterations
79
+ self.parallel = parallel
80
+ self.worker_count = worker_count
81
+ self.collect_file_metrics = collect_file_metrics
82
+ self.collect_worker_metrics = collect_worker_metrics
83
+ self.progress_callback = progress_callback
84
+
85
+ # Configuration object
86
+ self.config = BenchmarkConfig(
87
+ mode=BenchmarkMode.PARALLEL if parallel else BenchmarkMode.SEQUENTIAL,
88
+ worker_count=worker_count,
89
+ iterations=iterations,
90
+ warmup_iterations=warmup_iterations,
91
+ collect_file_metrics=collect_file_metrics,
92
+ collect_worker_metrics=collect_worker_metrics,
93
+ )
94
+
95
+ # Storage for database operations
96
+ self._storage_dir = get_shotgun_home() / "codebases"
97
+ self._service = get_codebase_service(self._storage_dir)
98
+
99
+ async def run(self) -> BenchmarkResults:
100
+ """Run all benchmark iterations and return aggregated results.
101
+
102
+ Returns:
103
+ BenchmarkResults with all run data and statistics
104
+ """
105
+ results = BenchmarkResults(
106
+ codebase_name=self.codebase_name,
107
+ codebase_path=str(self.codebase_path),
108
+ config=self.config,
109
+ )
110
+
111
+ # Run warmup iterations
112
+ for i in range(self.warmup_iterations):
113
+ logger.info(f"Running warmup iteration {i + 1}/{self.warmup_iterations}...")
114
+ if self.progress_callback:
115
+ self.progress_callback(
116
+ f"Warmup {i + 1}/{self.warmup_iterations}", None, None
117
+ )
118
+
119
+ run = await self._run_single_iteration(
120
+ run_id=i,
121
+ is_warmup=True,
122
+ )
123
+ results.add_run(run)
124
+ await self._cleanup_database()
125
+
126
+ # Run measured iterations
127
+ for i in range(self.iterations):
128
+ logger.info(f"Running benchmark iteration {i + 1}/{self.iterations}...")
129
+ if self.progress_callback:
130
+ self.progress_callback(
131
+ f"Benchmark {i + 1}/{self.iterations}", None, None
132
+ )
133
+
134
+ run = await self._run_single_iteration(
135
+ run_id=i,
136
+ is_warmup=False,
137
+ )
138
+ results.add_run(run)
139
+
140
+ # Clean up between iterations (but not after the last one)
141
+ if i < self.iterations - 1:
142
+ await self._cleanup_database()
143
+
144
+ # Register the codebase so it persists after benchmark
145
+ await self._register_codebase()
146
+
147
+ # Calculate statistics
148
+ results.calculate_statistics()
149
+
150
+ logger.info(
151
+ f"Benchmark complete: {self.iterations} iterations, "
152
+ f"avg {results.avg_duration_seconds:.2f}s"
153
+ )
154
+
155
+ return results
156
+
157
+ async def _run_single_iteration(
158
+ self,
159
+ run_id: int,
160
+ is_warmup: bool,
161
+ ) -> BenchmarkRun:
162
+ """Run a single benchmark iteration.
163
+
164
+ Args:
165
+ run_id: Run number
166
+ is_warmup: Whether this is a warmup run
167
+
168
+ Returns:
169
+ BenchmarkRun with collected metrics
170
+ """
171
+ # Create metrics collector
172
+ metrics_collector = MetricsCollector(
173
+ codebase_name=self.codebase_name,
174
+ collect_file_metrics=self.collect_file_metrics,
175
+ collect_worker_metrics=self.collect_worker_metrics,
176
+ )
177
+
178
+ # Generate unique graph ID for this run
179
+ graph_id = _compute_graph_id(self.codebase_path)
180
+
181
+ # Create database
182
+ kuzu = get_kuzu()
183
+ graph_path = self._storage_dir / f"{graph_id}.kuzu"
184
+
185
+ # Ensure clean state
186
+ if graph_path.exists():
187
+ if graph_path.is_dir():
188
+ shutil.rmtree(graph_path)
189
+ else:
190
+ graph_path.unlink()
191
+ wal_path = self._storage_dir / f"{graph_id}.kuzu.wal"
192
+ if wal_path.exists():
193
+ wal_path.unlink()
194
+
195
+ # Create database and connection
196
+ db = kuzu.Database(str(graph_path))
197
+ conn = kuzu.Connection(db)
198
+
199
+ # Load parsers
200
+ parsers, queries = load_parsers()
201
+
202
+ # Create ingestor and builder
203
+ ingestor = Ingestor(conn)
204
+ ingestor.create_schema()
205
+
206
+ builder = SimpleGraphBuilder(
207
+ ingestor=ingestor,
208
+ repo_path=self.codebase_path,
209
+ parsers=parsers,
210
+ queries=queries,
211
+ metrics_collector=metrics_collector,
212
+ enable_parallel=self.parallel,
213
+ progress_callback=None, # Disable TUI progress in benchmark mode
214
+ )
215
+
216
+ # Run indexing
217
+ await builder.run()
218
+
219
+ # Get metrics
220
+ metrics = metrics_collector.get_metrics()
221
+
222
+ # Close connection
223
+ del conn
224
+ del db
225
+
226
+ return BenchmarkRun(
227
+ run_id=run_id,
228
+ is_warmup=is_warmup,
229
+ metrics=metrics,
230
+ )
231
+
232
+ async def _cleanup_database(self) -> None:
233
+ """Delete database files and clear caches between runs."""
234
+ graph_id = _compute_graph_id(self.codebase_path)
235
+
236
+ # Delete database file
237
+ graph_path = self._storage_dir / f"{graph_id}.kuzu"
238
+ if graph_path.exists():
239
+ if graph_path.is_dir():
240
+ shutil.rmtree(graph_path)
241
+ else:
242
+ graph_path.unlink()
243
+ logger.debug(f"Deleted database: {graph_path}")
244
+
245
+ # Delete WAL file
246
+ wal_path = self._storage_dir / f"{graph_id}.kuzu.wal"
247
+ if wal_path.exists():
248
+ wal_path.unlink()
249
+ logger.debug(f"Deleted WAL: {wal_path}")
250
+
251
+ # Force garbage collection
252
+ gc.collect()
253
+
254
+ async def _register_codebase(self) -> None:
255
+ """Register the codebase so it appears in `shotgun codebase list`.
256
+
257
+ This creates a Project node in the database with metadata that
258
+ identifies the indexed codebase.
259
+ """
260
+ graph_id = _compute_graph_id(self.codebase_path)
261
+ graph_path = self._storage_dir / f"{graph_id}.kuzu"
262
+
263
+ if not graph_path.exists():
264
+ logger.warning("Cannot register codebase: database not found")
265
+ return
266
+
267
+ kuzu = get_kuzu()
268
+ db = kuzu.Database(str(graph_path))
269
+ conn = kuzu.Connection(db)
270
+
271
+ try:
272
+ # Check if Project node already exists
273
+ result = conn.execute("MATCH (p:Project) RETURN p.graph_id LIMIT 1")
274
+ if result.has_next():
275
+ logger.debug("Project node already exists, skipping registration")
276
+ return
277
+
278
+ # Create Project node with metadata
279
+ current_time = int(time.time())
280
+ conn.execute(
281
+ """
282
+ CREATE (p:Project {
283
+ name: $name,
284
+ repo_path: $repo_path,
285
+ graph_id: $graph_id,
286
+ created_at: $created_at,
287
+ updated_at: $updated_at,
288
+ schema_version: $schema_version,
289
+ build_options: $build_options,
290
+ indexed_from_cwds: $indexed_from_cwds
291
+ })
292
+ """,
293
+ {
294
+ "name": self.codebase_name,
295
+ "repo_path": str(self.codebase_path),
296
+ "graph_id": graph_id,
297
+ "created_at": current_time,
298
+ "updated_at": current_time,
299
+ "schema_version": "1.0.0",
300
+ "build_options": json.dumps({}),
301
+ "indexed_from_cwds": json.dumps([str(Path.cwd())]),
302
+ },
303
+ )
304
+ logger.info(
305
+ f"Registered codebase '{self.codebase_name}' with graph_id: {graph_id}"
306
+ )
307
+ finally:
308
+ del conn
309
+ del db