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.
Files changed (40) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/agent_detection.py +41 -2
  3. htmlgraph/analytics/cli.py +86 -20
  4. htmlgraph/cli.py +519 -87
  5. htmlgraph/collections/base.py +68 -4
  6. htmlgraph/docs/__init__.py +77 -0
  7. htmlgraph/docs/docs_version.py +55 -0
  8. htmlgraph/docs/metadata.py +93 -0
  9. htmlgraph/docs/migrations.py +232 -0
  10. htmlgraph/docs/template_engine.py +143 -0
  11. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  12. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  13. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  14. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  15. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  16. htmlgraph/docs/version_check.py +161 -0
  17. htmlgraph/git_events.py +61 -7
  18. htmlgraph/operations/README.md +62 -0
  19. htmlgraph/operations/__init__.py +61 -0
  20. htmlgraph/operations/analytics.py +338 -0
  21. htmlgraph/operations/events.py +243 -0
  22. htmlgraph/operations/hooks.py +349 -0
  23. htmlgraph/operations/server.py +302 -0
  24. htmlgraph/orchestration/__init__.py +39 -0
  25. htmlgraph/orchestration/headless_spawner.py +566 -0
  26. htmlgraph/orchestration/model_selection.py +323 -0
  27. htmlgraph/orchestrator-system-prompt-optimized.txt +47 -0
  28. htmlgraph/parser.py +56 -1
  29. htmlgraph/sdk.py +529 -7
  30. htmlgraph/server.py +153 -60
  31. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/METADATA +3 -1
  32. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/RECORD +40 -19
  33. /htmlgraph/{orchestration.py → orchestration/task_coordination.py} +0 -0
  34. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/dashboard.html +0 -0
  35. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/styles.css +0 -0
  36. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  37. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  38. {htmlgraph-0.21.0.data → htmlgraph-0.23.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  39. {htmlgraph-0.21.0.dist-info → htmlgraph-0.23.0.dist-info}/WHEEL +0 -0
  40. {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
+ ]