htmlgraph 0.21.0__py3-none-any.whl → 0.23.0__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/__init__.py +1 -1
- htmlgraph/agent_detection.py +41 -2
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/cli.py +519 -87
- htmlgraph/collections/base.py +68 -4
- 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 +161 -0
- htmlgraph/git_events.py +61 -7
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +61 -0
- htmlgraph/operations/analytics.py +338 -0
- htmlgraph/operations/events.py +243 -0
- htmlgraph/operations/hooks.py +349 -0
- htmlgraph/operations/server.py +302 -0
- htmlgraph/orchestration/__init__.py +39 -0
- htmlgraph/orchestration/headless_spawner.py +566 -0
- htmlgraph/orchestration/model_selection.py +323 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +47 -0
- htmlgraph/parser.py +56 -1
- htmlgraph/sdk.py +529 -7
- htmlgraph/server.py +153 -60
- {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/METADATA +3 -1
- {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/RECORD +40 -19
- /htmlgraph/{orchestration.py → orchestration/task_coordination.py} +0 -0
- {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/WHEEL +0 -0
- {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Git hook operations for HtmlGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class HookInstallResult:
|
|
14
|
+
installed: list[str]
|
|
15
|
+
skipped: list[str]
|
|
16
|
+
warnings: list[str]
|
|
17
|
+
config_used: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class HookListResult:
|
|
22
|
+
enabled: list[str]
|
|
23
|
+
disabled: list[str]
|
|
24
|
+
missing: list[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class HookValidationResult:
|
|
29
|
+
valid: bool
|
|
30
|
+
errors: list[str]
|
|
31
|
+
warnings: list[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HookConfigError(ValueError):
|
|
35
|
+
"""Invalid hook configuration."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HookInstallError(RuntimeError):
|
|
39
|
+
"""Hook installation failed."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_hook_config(project_dir: Path) -> dict[str, Any]:
|
|
43
|
+
"""Load hook configuration from project."""
|
|
44
|
+
config_path = project_dir / ".htmlgraph" / "hooks-config.json"
|
|
45
|
+
|
|
46
|
+
# Default configuration
|
|
47
|
+
default_config: dict[str, Any] = {
|
|
48
|
+
"enabled_hooks": [
|
|
49
|
+
"post-commit",
|
|
50
|
+
"post-checkout",
|
|
51
|
+
"post-merge",
|
|
52
|
+
"pre-push",
|
|
53
|
+
],
|
|
54
|
+
"use_symlinks": True,
|
|
55
|
+
"backup_existing": True,
|
|
56
|
+
"chain_existing": True,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if not config_path.exists():
|
|
60
|
+
return default_config
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with open(config_path, encoding="utf-8") as f:
|
|
64
|
+
user_config = json.load(f)
|
|
65
|
+
# Merge with defaults
|
|
66
|
+
config = default_config.copy()
|
|
67
|
+
config.update(user_config)
|
|
68
|
+
return config
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise HookConfigError(f"Failed to load hook config: {e}") from e
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_environment(project_dir: Path) -> None:
|
|
74
|
+
"""Validate that environment is ready for hook installation."""
|
|
75
|
+
git_dir = project_dir / ".git"
|
|
76
|
+
htmlgraph_dir = project_dir / ".htmlgraph"
|
|
77
|
+
|
|
78
|
+
if not git_dir.exists():
|
|
79
|
+
raise HookInstallError("Not a git repository (no .git directory found)")
|
|
80
|
+
|
|
81
|
+
if not htmlgraph_dir.exists():
|
|
82
|
+
raise HookInstallError("HtmlGraph not initialized (run 'htmlgraph init' first)")
|
|
83
|
+
|
|
84
|
+
git_hooks_dir = git_dir / "hooks"
|
|
85
|
+
if not git_hooks_dir.exists():
|
|
86
|
+
try:
|
|
87
|
+
git_hooks_dir.mkdir(parents=True)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise HookInstallError(f"Cannot create .git/hooks directory: {e}") from e
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _install_single_hook(
|
|
93
|
+
hook_name: str,
|
|
94
|
+
*,
|
|
95
|
+
project_dir: Path,
|
|
96
|
+
config: dict[str, Any],
|
|
97
|
+
force: bool = False,
|
|
98
|
+
) -> tuple[bool, str]:
|
|
99
|
+
"""Install a single git hook."""
|
|
100
|
+
from htmlgraph.hooks import HOOKS_DIR
|
|
101
|
+
|
|
102
|
+
# Check if hook is enabled
|
|
103
|
+
if hook_name not in config.get("enabled_hooks", []):
|
|
104
|
+
return False, f"Hook '{hook_name}' is disabled in configuration"
|
|
105
|
+
|
|
106
|
+
# Source hook file
|
|
107
|
+
hook_source = HOOKS_DIR / f"{hook_name}.sh"
|
|
108
|
+
if not hook_source.exists():
|
|
109
|
+
return False, f"Hook template not found: {hook_source}"
|
|
110
|
+
|
|
111
|
+
# Destination in .htmlgraph/hooks/ (versioned)
|
|
112
|
+
htmlgraph_dir = project_dir / ".htmlgraph"
|
|
113
|
+
versioned_hooks_dir = htmlgraph_dir / "hooks"
|
|
114
|
+
versioned_hooks_dir.mkdir(exist_ok=True)
|
|
115
|
+
hook_dest = versioned_hooks_dir / f"{hook_name}.sh"
|
|
116
|
+
|
|
117
|
+
# Git hooks directory
|
|
118
|
+
git_dir = project_dir / ".git"
|
|
119
|
+
git_hooks_dir = git_dir / "hooks"
|
|
120
|
+
git_hook_path = git_hooks_dir / hook_name
|
|
121
|
+
|
|
122
|
+
# Copy hook to .htmlgraph/hooks/ (versioned)
|
|
123
|
+
try:
|
|
124
|
+
shutil.copy(hook_source, hook_dest)
|
|
125
|
+
hook_dest.chmod(0o755)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
raise HookInstallError(f"Failed to copy hook to {hook_dest}: {e}") from e
|
|
128
|
+
|
|
129
|
+
# Handle existing git hook
|
|
130
|
+
if git_hook_path.exists() and not force:
|
|
131
|
+
if config.get("backup_existing", True):
|
|
132
|
+
backup_path = git_hook_path.with_suffix(".backup")
|
|
133
|
+
if not backup_path.exists():
|
|
134
|
+
shutil.copy(git_hook_path, backup_path)
|
|
135
|
+
|
|
136
|
+
if config.get("chain_existing", True):
|
|
137
|
+
# Create chained hook
|
|
138
|
+
return _create_chained_hook(
|
|
139
|
+
hook_name, hook_dest, git_hook_path, backup_path
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
return False, (
|
|
143
|
+
f"Hook {hook_name} already exists. "
|
|
144
|
+
f"Backed up to {backup_path}. "
|
|
145
|
+
f"Use force=True to overwrite."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Install hook (symlink or copy)
|
|
149
|
+
use_symlinks = not force and config.get("use_symlinks", True)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
if use_symlinks:
|
|
153
|
+
# Remove existing symlink if present
|
|
154
|
+
if git_hook_path.is_symlink():
|
|
155
|
+
git_hook_path.unlink()
|
|
156
|
+
|
|
157
|
+
git_hook_path.symlink_to(hook_dest.resolve())
|
|
158
|
+
return True, f"Installed {hook_name} (symlink)"
|
|
159
|
+
else:
|
|
160
|
+
shutil.copy(hook_dest, git_hook_path)
|
|
161
|
+
git_hook_path.chmod(0o755)
|
|
162
|
+
return True, f"Installed {hook_name} (copy)"
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise HookInstallError(f"Failed to install {hook_name}: {e}") from e
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _create_chained_hook(
|
|
168
|
+
hook_name: str, htmlgraph_hook: Path, git_hook: Path, backup_hook: Path
|
|
169
|
+
) -> tuple[bool, str]:
|
|
170
|
+
"""Create a chained hook that runs both existing and HtmlGraph hooks."""
|
|
171
|
+
chain_content = f'''#!/bin/bash
|
|
172
|
+
# Chained hook - runs existing hook then HtmlGraph hook
|
|
173
|
+
|
|
174
|
+
# Run existing hook
|
|
175
|
+
if [ -f "{backup_hook}" ]; then
|
|
176
|
+
"{backup_hook}" || exit $?
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# Run HtmlGraph hook
|
|
180
|
+
if [ -f "{htmlgraph_hook}" ]; then
|
|
181
|
+
"{htmlgraph_hook}" || true
|
|
182
|
+
fi
|
|
183
|
+
'''
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
git_hook.write_text(chain_content, encoding="utf-8")
|
|
187
|
+
git_hook.chmod(0o755)
|
|
188
|
+
return True, f"Installed {hook_name} (chained)"
|
|
189
|
+
except Exception as e:
|
|
190
|
+
raise HookInstallError(f"Failed to create chained hook: {e}") from e
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def install_hooks(*, project_dir: Path, use_copy: bool = False) -> HookInstallResult:
|
|
194
|
+
"""
|
|
195
|
+
Install HtmlGraph git hooks into the project.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
project_dir: Project root directory
|
|
199
|
+
use_copy: Force copy instead of symlink
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
HookInstallResult with installation details
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
HookInstallError: If installation fails
|
|
206
|
+
HookConfigError: If configuration is invalid
|
|
207
|
+
"""
|
|
208
|
+
project_dir = Path(project_dir).resolve()
|
|
209
|
+
|
|
210
|
+
# Validate environment
|
|
211
|
+
_validate_environment(project_dir)
|
|
212
|
+
|
|
213
|
+
# Load configuration
|
|
214
|
+
config = _load_hook_config(project_dir)
|
|
215
|
+
|
|
216
|
+
# Override use_symlinks if use_copy is requested
|
|
217
|
+
if use_copy:
|
|
218
|
+
config["use_symlinks"] = False
|
|
219
|
+
|
|
220
|
+
installed: list[str] = []
|
|
221
|
+
skipped: list[str] = []
|
|
222
|
+
warnings: list[str] = []
|
|
223
|
+
|
|
224
|
+
# Install each enabled hook
|
|
225
|
+
for hook_name in config.get("enabled_hooks", []):
|
|
226
|
+
try:
|
|
227
|
+
success, message = _install_single_hook(
|
|
228
|
+
hook_name,
|
|
229
|
+
project_dir=project_dir,
|
|
230
|
+
config=config,
|
|
231
|
+
force=False,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if success:
|
|
235
|
+
installed.append(hook_name)
|
|
236
|
+
else:
|
|
237
|
+
skipped.append(hook_name)
|
|
238
|
+
warnings.append(message)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
skipped.append(hook_name)
|
|
241
|
+
warnings.append(f"{hook_name}: {e}")
|
|
242
|
+
|
|
243
|
+
return HookInstallResult(
|
|
244
|
+
installed=installed,
|
|
245
|
+
skipped=skipped,
|
|
246
|
+
warnings=warnings,
|
|
247
|
+
config_used=config,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def list_hooks(*, project_dir: Path) -> HookListResult:
|
|
252
|
+
"""
|
|
253
|
+
Return enabled/disabled/missing hooks for a project.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
project_dir: Project root directory
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
HookListResult with hook status
|
|
260
|
+
"""
|
|
261
|
+
from htmlgraph.hooks import AVAILABLE_HOOKS
|
|
262
|
+
|
|
263
|
+
project_dir = Path(project_dir).resolve()
|
|
264
|
+
git_dir = project_dir / ".git"
|
|
265
|
+
|
|
266
|
+
# Load configuration
|
|
267
|
+
try:
|
|
268
|
+
config = _load_hook_config(project_dir)
|
|
269
|
+
except HookConfigError:
|
|
270
|
+
config = {"enabled_hooks": []}
|
|
271
|
+
|
|
272
|
+
enabled_hooks = config.get("enabled_hooks", [])
|
|
273
|
+
|
|
274
|
+
enabled: list[str] = []
|
|
275
|
+
disabled: list[str] = []
|
|
276
|
+
missing: list[str] = []
|
|
277
|
+
|
|
278
|
+
for hook_name in AVAILABLE_HOOKS:
|
|
279
|
+
git_hook_path = git_dir / "hooks" / hook_name
|
|
280
|
+
is_enabled = hook_name in enabled_hooks
|
|
281
|
+
|
|
282
|
+
if is_enabled:
|
|
283
|
+
if git_hook_path.exists():
|
|
284
|
+
enabled.append(hook_name)
|
|
285
|
+
else:
|
|
286
|
+
missing.append(hook_name)
|
|
287
|
+
else:
|
|
288
|
+
disabled.append(hook_name)
|
|
289
|
+
|
|
290
|
+
return HookListResult(
|
|
291
|
+
enabled=enabled,
|
|
292
|
+
disabled=disabled,
|
|
293
|
+
missing=missing,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def validate_hook_config(*, project_dir: Path) -> HookValidationResult:
|
|
298
|
+
"""
|
|
299
|
+
Validate hook configuration for a project.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
project_dir: Project root directory
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
HookValidationResult with validation status
|
|
306
|
+
"""
|
|
307
|
+
from htmlgraph.hooks import AVAILABLE_HOOKS
|
|
308
|
+
|
|
309
|
+
project_dir = Path(project_dir).resolve()
|
|
310
|
+
errors: list[str] = []
|
|
311
|
+
warnings: list[str] = []
|
|
312
|
+
|
|
313
|
+
# Check git repository
|
|
314
|
+
git_dir = project_dir / ".git"
|
|
315
|
+
if not git_dir.exists():
|
|
316
|
+
errors.append("Not a git repository (no .git directory found)")
|
|
317
|
+
|
|
318
|
+
# Check HtmlGraph initialization
|
|
319
|
+
htmlgraph_dir = project_dir / ".htmlgraph"
|
|
320
|
+
if not htmlgraph_dir.exists():
|
|
321
|
+
errors.append("HtmlGraph not initialized (run 'htmlgraph init' first)")
|
|
322
|
+
|
|
323
|
+
# Load and validate configuration
|
|
324
|
+
try:
|
|
325
|
+
config = _load_hook_config(project_dir)
|
|
326
|
+
|
|
327
|
+
# Validate enabled_hooks
|
|
328
|
+
enabled_hooks = config.get("enabled_hooks", [])
|
|
329
|
+
if not isinstance(enabled_hooks, list):
|
|
330
|
+
errors.append("enabled_hooks must be a list")
|
|
331
|
+
else:
|
|
332
|
+
for hook_name in enabled_hooks:
|
|
333
|
+
if hook_name not in AVAILABLE_HOOKS:
|
|
334
|
+
warnings.append(f"Unknown hook '{hook_name}' in configuration")
|
|
335
|
+
|
|
336
|
+
# Validate boolean options
|
|
337
|
+
for key in ["use_symlinks", "backup_existing", "chain_existing"]:
|
|
338
|
+
value = config.get(key)
|
|
339
|
+
if value is not None and not isinstance(value, bool):
|
|
340
|
+
errors.append(f"{key} must be a boolean")
|
|
341
|
+
|
|
342
|
+
except HookConfigError as e:
|
|
343
|
+
errors.append(str(e))
|
|
344
|
+
|
|
345
|
+
return HookValidationResult(
|
|
346
|
+
valid=len(errors) == 0,
|
|
347
|
+
errors=errors,
|
|
348
|
+
warnings=warnings,
|
|
349
|
+
)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Server operations for HtmlGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ServerHandle:
|
|
12
|
+
url: str
|
|
13
|
+
port: int
|
|
14
|
+
host: str
|
|
15
|
+
server: Any | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ServerStatus:
|
|
20
|
+
running: bool
|
|
21
|
+
url: str | None = None
|
|
22
|
+
port: int | None = None
|
|
23
|
+
host: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ServerStartResult:
|
|
28
|
+
handle: ServerHandle
|
|
29
|
+
warnings: list[str]
|
|
30
|
+
config_used: dict[str, Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ServerStartError(RuntimeError):
|
|
34
|
+
"""Server failed to start."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PortInUseError(ServerStartError):
|
|
38
|
+
"""Requested port is already in use."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def start_server(
|
|
42
|
+
*,
|
|
43
|
+
port: int,
|
|
44
|
+
graph_dir: Path,
|
|
45
|
+
static_dir: Path,
|
|
46
|
+
host: str = "localhost",
|
|
47
|
+
watch: bool = True,
|
|
48
|
+
auto_port: bool = False,
|
|
49
|
+
) -> ServerStartResult:
|
|
50
|
+
"""
|
|
51
|
+
Start the HtmlGraph server with validated configuration.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
port: Port to listen on
|
|
55
|
+
graph_dir: Directory containing graph data (.htmlgraph/)
|
|
56
|
+
static_dir: Directory for static files (index.html, etc.)
|
|
57
|
+
host: Host to bind to
|
|
58
|
+
watch: Enable file watching for auto-reload
|
|
59
|
+
auto_port: Automatically find available port if specified port is in use
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
ServerStartResult with handle, warnings, and config used
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
PortInUseError: If port is in use and auto_port=False
|
|
66
|
+
ServerStartError: If server fails to start
|
|
67
|
+
"""
|
|
68
|
+
from http.server import HTTPServer
|
|
69
|
+
|
|
70
|
+
from htmlgraph.analytics_index import AnalyticsIndex
|
|
71
|
+
from htmlgraph.event_log import JsonlEventLog
|
|
72
|
+
from htmlgraph.file_watcher import GraphWatcher
|
|
73
|
+
from htmlgraph.graph import HtmlGraph
|
|
74
|
+
from htmlgraph.server import HtmlGraphAPIHandler, sync_dashboard_files
|
|
75
|
+
|
|
76
|
+
warnings: list[str] = []
|
|
77
|
+
original_port = port
|
|
78
|
+
|
|
79
|
+
# Handle auto-port selection
|
|
80
|
+
if auto_port and _check_port_in_use(port, host):
|
|
81
|
+
port = _find_available_port(port + 1)
|
|
82
|
+
warnings.append(f"Port {original_port} is in use, using {port} instead")
|
|
83
|
+
|
|
84
|
+
# Check if port is still in use (and we're not in auto-port mode)
|
|
85
|
+
if not auto_port and _check_port_in_use(port, host):
|
|
86
|
+
raise PortInUseError(
|
|
87
|
+
f"Port {port} is already in use. Use auto_port=True or choose a different port."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Auto-sync dashboard files
|
|
91
|
+
try:
|
|
92
|
+
if sync_dashboard_files(static_dir):
|
|
93
|
+
warnings.append(
|
|
94
|
+
"Dashboard files out of sync, synced dashboard.html → index.html"
|
|
95
|
+
)
|
|
96
|
+
except PermissionError as e:
|
|
97
|
+
warnings.append(f"Unable to sync dashboard files: {e}")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
warnings.append(f"Error during dashboard sync: {e}")
|
|
100
|
+
|
|
101
|
+
# Create graph directories
|
|
102
|
+
graph_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
for collection in HtmlGraphAPIHandler.COLLECTIONS:
|
|
104
|
+
(graph_dir / collection).mkdir(exist_ok=True)
|
|
105
|
+
|
|
106
|
+
# Copy default stylesheet
|
|
107
|
+
styles_dest = graph_dir / "styles.css"
|
|
108
|
+
if not styles_dest.exists():
|
|
109
|
+
styles_src = Path(__file__).parent.parent / "styles.css"
|
|
110
|
+
if styles_src.exists():
|
|
111
|
+
styles_dest.write_text(styles_src.read_text())
|
|
112
|
+
|
|
113
|
+
# Build analytics index if needed
|
|
114
|
+
events_dir = graph_dir / "events"
|
|
115
|
+
db_path = graph_dir / "index.sqlite"
|
|
116
|
+
index_needs_build = (
|
|
117
|
+
not db_path.exists() and events_dir.exists() and any(events_dir.glob("*.jsonl"))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if index_needs_build:
|
|
121
|
+
try:
|
|
122
|
+
log = JsonlEventLog(events_dir)
|
|
123
|
+
index = AnalyticsIndex(db_path)
|
|
124
|
+
events = (event for _, event in log.iter_events())
|
|
125
|
+
index.rebuild_from_events(events)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
warnings.append(f"Failed to build analytics index: {e}")
|
|
128
|
+
|
|
129
|
+
# Configure handler
|
|
130
|
+
HtmlGraphAPIHandler.graph_dir = graph_dir
|
|
131
|
+
HtmlGraphAPIHandler.static_dir = static_dir
|
|
132
|
+
HtmlGraphAPIHandler.graphs = {}
|
|
133
|
+
HtmlGraphAPIHandler.analytics_db = None
|
|
134
|
+
|
|
135
|
+
# Start HTTP server
|
|
136
|
+
try:
|
|
137
|
+
server = HTTPServer((host, port), HtmlGraphAPIHandler)
|
|
138
|
+
except OSError as e:
|
|
139
|
+
if e.errno == 48 or "Address already in use" in str(e):
|
|
140
|
+
raise PortInUseError(f"Port {port} is already in use") from e
|
|
141
|
+
raise ServerStartError(f"Failed to start server: {e}") from e
|
|
142
|
+
|
|
143
|
+
# Start file watcher if enabled
|
|
144
|
+
watcher = None
|
|
145
|
+
if watch:
|
|
146
|
+
|
|
147
|
+
def get_graph(collection: str) -> HtmlGraph:
|
|
148
|
+
"""Callback to get graph instance for a collection."""
|
|
149
|
+
handler = HtmlGraphAPIHandler
|
|
150
|
+
if collection not in handler.graphs:
|
|
151
|
+
collection_dir = handler.graph_dir / collection
|
|
152
|
+
handler.graphs[collection] = HtmlGraph(
|
|
153
|
+
collection_dir, stylesheet_path="../styles.css", auto_load=True
|
|
154
|
+
)
|
|
155
|
+
return handler.graphs[collection]
|
|
156
|
+
|
|
157
|
+
watcher = GraphWatcher(
|
|
158
|
+
graph_dir=graph_dir,
|
|
159
|
+
collections=HtmlGraphAPIHandler.COLLECTIONS,
|
|
160
|
+
get_graph_callback=get_graph,
|
|
161
|
+
)
|
|
162
|
+
watcher.start()
|
|
163
|
+
|
|
164
|
+
# Create handle
|
|
165
|
+
handle = ServerHandle(
|
|
166
|
+
url=f"http://{host}:{port}",
|
|
167
|
+
port=port,
|
|
168
|
+
host=host,
|
|
169
|
+
server={"httpserver": server, "watcher": watcher},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Configuration used
|
|
173
|
+
config_used = {
|
|
174
|
+
"port": port,
|
|
175
|
+
"original_port": original_port,
|
|
176
|
+
"host": host,
|
|
177
|
+
"graph_dir": str(graph_dir),
|
|
178
|
+
"static_dir": str(static_dir),
|
|
179
|
+
"watch": watch,
|
|
180
|
+
"auto_port": auto_port,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return ServerStartResult(
|
|
184
|
+
handle=handle,
|
|
185
|
+
warnings=warnings,
|
|
186
|
+
config_used=config_used,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def stop_server(handle: ServerHandle) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Stop a running HtmlGraph server.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
handle: ServerHandle returned from start_server()
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ServerStartError: If shutdown fails
|
|
199
|
+
"""
|
|
200
|
+
if handle.server is None:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
# Extract server components
|
|
205
|
+
if isinstance(handle.server, dict):
|
|
206
|
+
httpserver = handle.server.get("httpserver")
|
|
207
|
+
watcher = handle.server.get("watcher")
|
|
208
|
+
|
|
209
|
+
# Stop file watcher first
|
|
210
|
+
if watcher is not None:
|
|
211
|
+
try:
|
|
212
|
+
watcher.stop()
|
|
213
|
+
except Exception:
|
|
214
|
+
pass # Best effort
|
|
215
|
+
|
|
216
|
+
# Shutdown HTTP server
|
|
217
|
+
if httpserver is not None:
|
|
218
|
+
httpserver.shutdown()
|
|
219
|
+
else:
|
|
220
|
+
# Assume it's the HTTPServer directly
|
|
221
|
+
handle.server.shutdown()
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise ServerStartError(f"Failed to stop server: {e}") from e
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_server_status(handle: ServerHandle | None = None) -> ServerStatus:
|
|
227
|
+
"""
|
|
228
|
+
Return server status for a handle or best-effort local check.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
handle: Optional ServerHandle to check
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
ServerStatus indicating whether server is running
|
|
235
|
+
"""
|
|
236
|
+
if handle is None:
|
|
237
|
+
# No handle provided - cannot determine status
|
|
238
|
+
return ServerStatus(running=False)
|
|
239
|
+
|
|
240
|
+
# Check if server is running by testing the port
|
|
241
|
+
try:
|
|
242
|
+
is_running = not _check_port_in_use(handle.port, handle.host)
|
|
243
|
+
return ServerStatus(
|
|
244
|
+
running=is_running,
|
|
245
|
+
url=handle.url if is_running else None,
|
|
246
|
+
port=handle.port if is_running else None,
|
|
247
|
+
host=handle.host if is_running else None,
|
|
248
|
+
)
|
|
249
|
+
except Exception:
|
|
250
|
+
return ServerStatus(running=False)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Helper functions (private)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _check_port_in_use(port: int, host: str = "localhost") -> bool:
|
|
257
|
+
"""
|
|
258
|
+
Check if a port is already in use.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
port: Port number to check
|
|
262
|
+
host: Host to check on
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
True if port is in use, False otherwise
|
|
266
|
+
"""
|
|
267
|
+
import socket
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
271
|
+
s.bind((host, port))
|
|
272
|
+
return False
|
|
273
|
+
except OSError:
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _find_available_port(start_port: int = 8080, max_attempts: int = 10) -> int:
|
|
278
|
+
"""
|
|
279
|
+
Find an available port starting from start_port.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
start_port: Port to start searching from
|
|
283
|
+
max_attempts: Maximum number of ports to try
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Available port number
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ServerStartError: If no available port found in range
|
|
290
|
+
"""
|
|
291
|
+
import socket
|
|
292
|
+
|
|
293
|
+
for port in range(start_port, start_port + max_attempts):
|
|
294
|
+
try:
|
|
295
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
296
|
+
s.bind(("", port))
|
|
297
|
+
return port
|
|
298
|
+
except OSError:
|
|
299
|
+
continue
|
|
300
|
+
raise ServerStartError(
|
|
301
|
+
f"No available ports found in range {start_port}-{start_port + max_attempts}"
|
|
302
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Orchestration utilities for multi-agent coordination."""
|
|
2
|
+
|
|
3
|
+
from .headless_spawner import AIResult, HeadlessSpawner
|
|
4
|
+
from .model_selection import (
|
|
5
|
+
BudgetMode,
|
|
6
|
+
ComplexityLevel,
|
|
7
|
+
ModelSelection,
|
|
8
|
+
TaskType,
|
|
9
|
+
get_fallback_chain,
|
|
10
|
+
select_model,
|
|
11
|
+
)
|
|
12
|
+
from .task_coordination import (
|
|
13
|
+
delegate_with_id,
|
|
14
|
+
generate_task_id,
|
|
15
|
+
get_results_by_task_id,
|
|
16
|
+
parallel_delegate,
|
|
17
|
+
save_task_results,
|
|
18
|
+
validate_and_save,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# Headless AI spawning
|
|
23
|
+
"HeadlessSpawner",
|
|
24
|
+
"AIResult",
|
|
25
|
+
# Model selection
|
|
26
|
+
"ModelSelection",
|
|
27
|
+
"TaskType",
|
|
28
|
+
"ComplexityLevel",
|
|
29
|
+
"BudgetMode",
|
|
30
|
+
"select_model",
|
|
31
|
+
"get_fallback_chain",
|
|
32
|
+
# Task coordination
|
|
33
|
+
"delegate_with_id",
|
|
34
|
+
"generate_task_id",
|
|
35
|
+
"get_results_by_task_id",
|
|
36
|
+
"parallel_delegate",
|
|
37
|
+
"save_task_results",
|
|
38
|
+
"validate_and_save",
|
|
39
|
+
]
|