Coverage for src/workstack/status/orchestrator.py: 69%
72 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1"""Orchestrator for collecting and assembling status information."""
3import logging
4from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed
5from pathlib import Path
7from workstack.core.context import WorkstackContext
8from workstack.status.collectors.base import StatusCollector
9from workstack.status.models.status_data import StatusData, WorktreeInfo
11logger = logging.getLogger(__name__)
14class StatusOrchestrator:
15 """Coordinates all status collectors and assembles final data.
17 The orchestrator runs collectors in parallel with timeouts to ensure
18 responsive output even if some collectors are slow or fail.
19 """
21 def __init__(self, collectors: list[StatusCollector], *, timeout_seconds: float = 2.0) -> None:
22 """Create a status orchestrator.
24 Args:
25 collectors: List of status collectors to run
26 timeout_seconds: Maximum time to wait for each collector (default: 2.0)
27 """
28 self.collectors = collectors
29 self.timeout_seconds = timeout_seconds
31 def collect_status(
32 self, ctx: WorkstackContext, worktree_path: Path, repo_root: Path
33 ) -> StatusData:
34 """Collect all status information in parallel.
36 Each collector runs in its own thread with a timeout. Failed or slow
37 collectors will return None for their section.
39 Args:
40 ctx: Workstack context with operations
41 worktree_path: Path to the worktree
42 repo_root: Path to repository root
44 Returns:
45 StatusData with all collected information
46 """
47 # Determine worktree info
48 worktree_info = self._get_worktree_info(ctx, worktree_path, repo_root)
50 # Run collectors in parallel
51 results: dict[str, object] = {}
53 with ThreadPoolExecutor(max_workers=5) as executor:
54 # Submit all available collectors
55 futures = {}
56 for collector in self.collectors:
57 if collector.is_available(ctx, worktree_path):
58 future = executor.submit(collector.collect, ctx, worktree_path, repo_root)
59 futures[future] = collector.name
61 # Collect results with timeout per collector
62 # Use a separate timeout for as_completed (total time for all collectors)
63 total_timeout = self.timeout_seconds * len(futures) if futures else 1.0
65 try:
66 for future in as_completed(futures, timeout=total_timeout):
67 collector_name = futures[future]
68 try:
69 result = future.result(timeout=0.1) # Should be immediate once complete
70 results[collector_name] = result
71 except TimeoutError:
72 # Error boundary: Collector timeouts shouldn't fail entire command
73 # Log for debugging but continue with other collectors
74 logger.debug(
75 f"Collector '{collector_name}' timed out after {self.timeout_seconds}s"
76 )
77 results[collector_name] = None
78 except Exception as e:
79 # Error boundary: Individual collector failures shouldn't fail
80 # entire command. This is an acceptable use of exception handling
81 # at error boundaries per EXCEPTION_HANDLING.md - parallel
82 # collectors should degrade gracefully
83 logger.debug(f"Collector '{collector_name}' failed: {e}")
84 results[collector_name] = None
85 except TimeoutError:
86 # Some collectors didn't complete in time
87 # Mark incomplete collectors as None
88 for future, collector_name in futures.items():
89 if future.running() or not future.done():
90 logger.debug(f"Collector '{collector_name}' did not complete in time")
91 results[collector_name] = None
93 # Get related worktrees
94 related_worktrees = self._get_related_worktrees(ctx, repo_root, worktree_path)
96 # Assemble StatusData - cast results to expected types
97 # Results are either the correct type or None (from collector failures)
98 from workstack.status.models.status_data import (
99 DependencyStatus,
100 EnvironmentStatus,
101 GitStatus,
102 PlanStatus,
103 PullRequestStatus,
104 StackPosition,
105 )
107 git_result = results.get("git")
108 stack_result = results.get("stack")
109 pr_result = results.get("pr")
110 env_result = results.get("environment")
111 deps_result = results.get("dependencies")
112 plan_result = results.get("plan")
114 return StatusData(
115 worktree_info=worktree_info,
116 git_status=git_result if isinstance(git_result, GitStatus) else None,
117 stack_position=stack_result if isinstance(stack_result, StackPosition) else None,
118 pr_status=pr_result if isinstance(pr_result, PullRequestStatus) else None,
119 environment=env_result if isinstance(env_result, EnvironmentStatus) else None,
120 dependencies=deps_result if isinstance(deps_result, DependencyStatus) else None,
121 plan=plan_result if isinstance(plan_result, PlanStatus) else None,
122 related_worktrees=related_worktrees,
123 )
125 def _get_worktree_info(
126 self, ctx: WorkstackContext, worktree_path: Path, repo_root: Path
127 ) -> WorktreeInfo:
128 """Get basic worktree information.
130 Args:
131 ctx: Workstack context
132 worktree_path: Path to worktree
133 repo_root: Path to repository root
135 Returns:
136 WorktreeInfo with basic information
137 """
138 # Check paths exist before resolution to avoid OSError
139 is_root = False
140 if worktree_path.exists() and repo_root.exists():
141 is_root = worktree_path.resolve() == repo_root.resolve()
143 name = "root" if is_root else worktree_path.name
144 branch = ctx.git_ops.get_current_branch(worktree_path)
146 return WorktreeInfo(name=name, path=worktree_path, branch=branch, is_root=is_root)
148 def _get_related_worktrees(
149 self, ctx: WorkstackContext, repo_root: Path, current_path: Path
150 ) -> list[WorktreeInfo]:
151 """Get list of other worktrees in the repository.
153 Args:
154 ctx: Workstack context
155 repo_root: Path to repository root
156 current_path: Path to current worktree (excluded from results)
158 Returns:
159 List of WorktreeInfo for other worktrees
160 """
161 worktrees = ctx.git_ops.list_worktrees(repo_root)
163 # Check paths exist before resolution to avoid OSError
164 if not current_path.exists():
165 return []
167 current_resolved = current_path.resolve()
169 related = []
170 for wt in worktrees:
171 # Skip if worktree path doesn't exist
172 if not wt.path.exists():
173 continue
175 wt_resolved = wt.path.resolve()
177 # Skip current worktree
178 if wt_resolved == current_resolved:
179 continue
181 # Determine if this is the root worktree
182 is_root = False
183 if repo_root.exists():
184 is_root = wt_resolved == repo_root.resolve()
186 name = "root" if is_root else wt.path.name
188 related.append(WorktreeInfo(name=name, path=wt.path, branch=wt.branch, is_root=is_root))
190 return related