gobby 0.2.8__py3-none-any.whl → 0.2.11__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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +5 -28
- gobby/adapters/codex_impl/adapter.py +38 -43
- gobby/adapters/copilot.py +324 -0
- gobby/adapters/cursor.py +373 -0
- gobby/adapters/gemini.py +2 -26
- gobby/adapters/windsurf.py +359 -0
- gobby/agents/definitions.py +162 -2
- gobby/agents/isolation.py +33 -1
- gobby/agents/pty_reader.py +192 -0
- gobby/agents/registry.py +10 -1
- gobby/agents/runner.py +24 -8
- gobby/agents/sandbox.py +8 -3
- gobby/agents/session.py +4 -0
- gobby/agents/spawn.py +9 -2
- gobby/agents/spawn_executor.py +49 -61
- gobby/agents/spawners/command_builder.py +4 -4
- gobby/app_context.py +64 -0
- gobby/cli/__init__.py +4 -0
- gobby/cli/install.py +259 -4
- gobby/cli/installers/__init__.py +12 -0
- gobby/cli/installers/copilot.py +242 -0
- gobby/cli/installers/cursor.py +244 -0
- gobby/cli/installers/shared.py +3 -0
- gobby/cli/installers/windsurf.py +242 -0
- gobby/cli/pipelines.py +639 -0
- gobby/cli/sessions.py +3 -1
- gobby/cli/skills.py +209 -0
- gobby/cli/tasks/crud.py +6 -5
- gobby/cli/tasks/search.py +1 -1
- gobby/cli/ui.py +116 -0
- gobby/cli/utils.py +5 -17
- gobby/cli/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +23 -2
- gobby/config/tasks.py +4 -0
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +92 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +487 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +27 -3
- gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
- gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
- gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
- gobby/llm/__init__.py +14 -1
- gobby/llm/claude.py +594 -43
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +66 -5
- gobby/mcp_proxy/server.py +6 -2
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/services/tool_filter.py +7 -0
- gobby/mcp_proxy/services/tool_proxy.py +19 -1
- gobby/mcp_proxy/stdio.py +37 -21
- gobby/mcp_proxy/tools/agents.py +7 -0
- gobby/mcp_proxy/tools/artifacts.py +3 -3
- gobby/mcp_proxy/tools/hub.py +30 -1
- gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
- gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
- gobby/mcp_proxy/tools/orchestration/review.py +17 -4
- gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
- gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
- gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
- gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
- gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
- gobby/mcp_proxy/tools/skills/__init__.py +184 -30
- gobby/mcp_proxy/tools/spawn_agent.py +229 -14
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/tasks/_context.py +8 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
- gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
- gobby/mcp_proxy/tools/tasks/_search.py +1 -1
- gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
- gobby/mcp_proxy/tools/workflows/_query.py +226 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/context.py +5 -5
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +131 -16
- gobby/servers/http.py +193 -150
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +56 -0
- gobby/servers/routes/mcp/endpoints/execution.py +33 -32
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +10 -1
- gobby/servers/routes/pipelines.py +227 -0
- gobby/servers/websocket.py +314 -1
- gobby/sessions/analyzer.py +89 -3
- gobby/sessions/manager.py +5 -5
- gobby/sessions/transcripts/__init__.py +3 -0
- gobby/sessions/transcripts/claude.py +5 -0
- gobby/sessions/transcripts/codex.py +5 -0
- gobby/sessions/transcripts/gemini.py +5 -0
- gobby/skills/hubs/__init__.py +25 -0
- gobby/skills/hubs/base.py +234 -0
- gobby/skills/hubs/claude_plugins.py +328 -0
- gobby/skills/hubs/clawdhub.py +289 -0
- gobby/skills/hubs/github_collection.py +465 -0
- gobby/skills/hubs/manager.py +263 -0
- gobby/skills/hubs/skillhub.py +342 -0
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/memories.py +4 -4
- gobby/storage/migrations.py +118 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +48 -8
- gobby/storage/tasks/_aggregates.py +2 -2
- gobby/storage/tasks/_lifecycle.py +4 -4
- gobby/storage/tasks/_models.py +7 -1
- gobby/storage/tasks/_queries.py +3 -3
- gobby/sync/memories.py +4 -3
- gobby/tasks/commits.py +48 -17
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +80 -0
- gobby/workflows/context_actions.py +265 -27
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +96 -0
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +26 -4
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +59 -27
- gobby/workflows/loader.py +567 -30
- gobby/workflows/lobster_compat.py +147 -0
- gobby/workflows/pipeline_executor.py +801 -0
- gobby/workflows/pipeline_state.py +172 -0
- gobby/workflows/pipeline_webhooks.py +206 -0
- gobby/workflows/premature_stop.py +5 -0
- gobby/worktrees/git.py +135 -20
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
- gobby/hooks/event_handlers.py +0 -1008
- gobby/mcp_proxy/tools/workflows.py +0 -1023
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/workflows/loader.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
5
|
|
|
6
6
|
import yaml
|
|
7
7
|
|
|
8
|
-
from .definitions import WorkflowDefinition
|
|
8
|
+
from .definitions import PipelineDefinition, WorkflowDefinition
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from gobby.agents.definitions import WorkflowSpec
|
|
9
12
|
|
|
10
13
|
logger = logging.getLogger(__name__)
|
|
11
14
|
|
|
@@ -15,7 +18,7 @@ class DiscoveredWorkflow:
|
|
|
15
18
|
"""A discovered workflow with metadata for ordering."""
|
|
16
19
|
|
|
17
20
|
name: str
|
|
18
|
-
definition: WorkflowDefinition
|
|
21
|
+
definition: WorkflowDefinition | PipelineDefinition
|
|
19
22
|
priority: int # Lower = higher priority (runs first)
|
|
20
23
|
is_project: bool # True if from project, False if global
|
|
21
24
|
path: Path
|
|
@@ -25,7 +28,7 @@ class WorkflowLoader:
|
|
|
25
28
|
def __init__(self, workflow_dirs: list[Path] | None = None):
|
|
26
29
|
# Default global workflow directory
|
|
27
30
|
self.global_dirs = workflow_dirs or [Path.home() / ".gobby" / "workflows"]
|
|
28
|
-
self._cache: dict[str, WorkflowDefinition] = {}
|
|
31
|
+
self._cache: dict[str, WorkflowDefinition | PipelineDefinition] = {}
|
|
29
32
|
# Cache for discovered workflows per project path
|
|
30
33
|
self._discovery_cache: dict[str, list[DiscoveredWorkflow]] = {}
|
|
31
34
|
|
|
@@ -34,19 +37,23 @@ class WorkflowLoader:
|
|
|
34
37
|
name: str,
|
|
35
38
|
project_path: Path | str | None = None,
|
|
36
39
|
_inheritance_chain: list[str] | None = None,
|
|
37
|
-
) -> WorkflowDefinition | None:
|
|
40
|
+
) -> WorkflowDefinition | PipelineDefinition | None:
|
|
38
41
|
"""
|
|
39
42
|
Load a workflow by name (without extension).
|
|
40
43
|
Supports inheritance via 'extends' field with cycle detection.
|
|
44
|
+
Auto-detects pipeline type and returns PipelineDefinition for type='pipeline'.
|
|
45
|
+
|
|
46
|
+
Qualified names (agent:workflow) are resolved by loading the inline workflow
|
|
47
|
+
from the agent definition.
|
|
41
48
|
|
|
42
49
|
Args:
|
|
43
|
-
name: Workflow name (without .yaml extension)
|
|
50
|
+
name: Workflow name (without .yaml extension), or qualified name (agent:workflow)
|
|
44
51
|
project_path: Optional project directory for project-specific workflows.
|
|
45
52
|
Searches: 1) {project_path}/.gobby/workflows/ 2) ~/.gobby/workflows/
|
|
46
53
|
_inheritance_chain: Internal parameter for cycle detection. Do not pass directly.
|
|
47
54
|
|
|
48
55
|
Raises:
|
|
49
|
-
ValueError: If circular inheritance is detected.
|
|
56
|
+
ValueError: If circular inheritance is detected or pipeline references are invalid.
|
|
50
57
|
"""
|
|
51
58
|
# Initialize or check inheritance chain for cycle detection
|
|
52
59
|
if _inheritance_chain is None:
|
|
@@ -56,10 +63,23 @@ class WorkflowLoader:
|
|
|
56
63
|
cycle_path = " -> ".join(_inheritance_chain + [name])
|
|
57
64
|
logger.error(f"Circular workflow inheritance detected: {cycle_path}")
|
|
58
65
|
raise ValueError(f"Circular workflow inheritance detected: {cycle_path}")
|
|
66
|
+
|
|
59
67
|
# Build cache key including project path for project-specific caching
|
|
60
68
|
cache_key = f"{project_path or 'global'}:{name}"
|
|
61
69
|
if cache_key in self._cache:
|
|
62
|
-
|
|
70
|
+
cached = self._cache[cache_key]
|
|
71
|
+
if isinstance(cached, (WorkflowDefinition, PipelineDefinition)):
|
|
72
|
+
return cached
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
# Check for qualified name (agent:workflow) - try to load from agent definition first
|
|
76
|
+
if ":" in name:
|
|
77
|
+
agent_workflow = self._load_from_agent_definition(name, project_path)
|
|
78
|
+
if agent_workflow:
|
|
79
|
+
self._cache[cache_key] = agent_workflow
|
|
80
|
+
return agent_workflow
|
|
81
|
+
# Fall through to file-based lookup (for backwards compatibility with
|
|
82
|
+
# persisted inline workflows like meeseeks-worker.yaml)
|
|
63
83
|
|
|
64
84
|
# Build search directories: project-specific first, then global
|
|
65
85
|
search_dirs = list(self.global_dirs)
|
|
@@ -92,33 +112,341 @@ class WorkflowLoader:
|
|
|
92
112
|
else:
|
|
93
113
|
logger.error(f"Parent workflow '{parent_name}' not found for '{name}'")
|
|
94
114
|
|
|
95
|
-
# 4.
|
|
96
|
-
|
|
115
|
+
# 4. Auto-detect pipeline type
|
|
116
|
+
if data.get("type") == "pipeline":
|
|
117
|
+
# Validate step references for pipelines
|
|
118
|
+
self._validate_pipeline_references(data)
|
|
119
|
+
definition: WorkflowDefinition | PipelineDefinition = PipelineDefinition(**data)
|
|
120
|
+
else:
|
|
121
|
+
definition = WorkflowDefinition(**data)
|
|
122
|
+
|
|
97
123
|
self._cache[cache_key] = definition
|
|
98
124
|
return definition
|
|
99
125
|
|
|
100
126
|
except ValueError:
|
|
101
|
-
# Re-raise ValueError (used for cycle detection)
|
|
127
|
+
# Re-raise ValueError (used for cycle detection and reference validation)
|
|
102
128
|
raise
|
|
103
129
|
except Exception as e:
|
|
104
130
|
logger.error(f"Failed to load workflow '{name}' from {path}: {e}", exc_info=True)
|
|
105
131
|
return None
|
|
106
132
|
|
|
133
|
+
def load_pipeline(
|
|
134
|
+
self,
|
|
135
|
+
name: str,
|
|
136
|
+
project_path: Path | str | None = None,
|
|
137
|
+
_inheritance_chain: list[str] | None = None,
|
|
138
|
+
) -> PipelineDefinition | None:
|
|
139
|
+
"""
|
|
140
|
+
Load a pipeline workflow by name (without extension).
|
|
141
|
+
Only returns workflows with type='pipeline'.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Pipeline name (without .yaml extension)
|
|
145
|
+
project_path: Optional project directory for project-specific pipelines.
|
|
146
|
+
Searches: 1) {project_path}/.gobby/workflows/ 2) ~/.gobby/workflows/
|
|
147
|
+
_inheritance_chain: Internal parameter for cycle detection. Do not pass directly.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
PipelineDefinition if found and type is 'pipeline', None otherwise.
|
|
151
|
+
"""
|
|
152
|
+
# Initialize or check inheritance chain for cycle detection
|
|
153
|
+
if _inheritance_chain is None:
|
|
154
|
+
_inheritance_chain = []
|
|
155
|
+
|
|
156
|
+
if name in _inheritance_chain:
|
|
157
|
+
cycle_path = " -> ".join(_inheritance_chain + [name])
|
|
158
|
+
logger.error(f"Circular pipeline inheritance detected: {cycle_path}")
|
|
159
|
+
raise ValueError(f"Circular pipeline inheritance detected: {cycle_path}")
|
|
160
|
+
|
|
161
|
+
# Build cache key including project path for project-specific caching
|
|
162
|
+
cache_key = f"pipeline:{project_path or 'global'}:{name}"
|
|
163
|
+
if cache_key in self._cache:
|
|
164
|
+
cached = self._cache[cache_key]
|
|
165
|
+
if isinstance(cached, PipelineDefinition):
|
|
166
|
+
return cached
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
# Build search directories: project-specific first, then global
|
|
170
|
+
search_dirs = list(self.global_dirs)
|
|
171
|
+
if project_path:
|
|
172
|
+
project_dir = Path(project_path) / ".gobby" / "workflows"
|
|
173
|
+
search_dirs.insert(0, project_dir)
|
|
174
|
+
|
|
175
|
+
# 1. Find file
|
|
176
|
+
path = self._find_workflow_file(name, search_dirs)
|
|
177
|
+
if not path:
|
|
178
|
+
logger.debug(f"Pipeline '{name}' not found in {search_dirs}")
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
# 2. Parse YAML
|
|
183
|
+
with open(path) as f:
|
|
184
|
+
data = yaml.safe_load(f)
|
|
185
|
+
|
|
186
|
+
# 3. Check if this is a pipeline type
|
|
187
|
+
if data.get("type") != "pipeline":
|
|
188
|
+
logger.debug(f"'{name}' is not a pipeline (type={data.get('type')})")
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
# 4. Handle inheritance with cycle detection
|
|
192
|
+
if "extends" in data:
|
|
193
|
+
parent_name = data["extends"]
|
|
194
|
+
# Add current pipeline to chain before loading parent
|
|
195
|
+
parent = self.load_pipeline(
|
|
196
|
+
parent_name,
|
|
197
|
+
project_path=project_path,
|
|
198
|
+
_inheritance_chain=_inheritance_chain + [name],
|
|
199
|
+
)
|
|
200
|
+
if parent:
|
|
201
|
+
data = self._merge_workflows(parent.model_dump(), data)
|
|
202
|
+
else:
|
|
203
|
+
logger.error(f"Parent pipeline '{parent_name}' not found for '{name}'")
|
|
204
|
+
|
|
205
|
+
# 5. Validate step references
|
|
206
|
+
self._validate_pipeline_references(data)
|
|
207
|
+
|
|
208
|
+
# 6. Validate and create model
|
|
209
|
+
definition = PipelineDefinition(**data)
|
|
210
|
+
self._cache[cache_key] = definition
|
|
211
|
+
return definition
|
|
212
|
+
|
|
213
|
+
except ValueError:
|
|
214
|
+
# Re-raise ValueError (used for cycle detection)
|
|
215
|
+
raise
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Failed to load pipeline '{name}' from {path}: {e}", exc_info=True)
|
|
218
|
+
return None
|
|
219
|
+
|
|
107
220
|
def _find_workflow_file(self, name: str, search_dirs: list[Path]) -> Path | None:
|
|
108
|
-
|
|
221
|
+
# Try both the original name and converted name (for inline workflows)
|
|
222
|
+
# "meeseeks:worker" -> also try "meeseeks-worker"
|
|
223
|
+
filenames = [f"{name}.yaml"]
|
|
224
|
+
if ":" in name:
|
|
225
|
+
filenames.append(f"{name.replace(':', '-')}.yaml")
|
|
226
|
+
|
|
109
227
|
for d in search_dirs:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
228
|
+
for filename in filenames:
|
|
229
|
+
# Check root directory
|
|
230
|
+
candidate = d / filename
|
|
231
|
+
if candidate.exists():
|
|
232
|
+
return candidate
|
|
233
|
+
# Check subdirectories (lifecycle/, etc.)
|
|
234
|
+
for subdir in d.iterdir() if d.exists() else []:
|
|
235
|
+
if subdir.is_dir():
|
|
236
|
+
candidate = subdir / filename
|
|
237
|
+
if candidate.exists():
|
|
238
|
+
return candidate
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
def _load_from_agent_definition(
|
|
242
|
+
self,
|
|
243
|
+
qualified_name: str,
|
|
244
|
+
project_path: Path | str | None = None,
|
|
245
|
+
) -> WorkflowDefinition | PipelineDefinition | None:
|
|
246
|
+
"""
|
|
247
|
+
Load an inline workflow from an agent definition.
|
|
248
|
+
|
|
249
|
+
Qualified names like "meeseeks:worker" are parsed to extract the agent name
|
|
250
|
+
and workflow name, then the workflow is loaded from the agent's workflows map.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
qualified_name: Qualified workflow name (e.g., "meeseeks:worker")
|
|
254
|
+
project_path: Project path for agent definition lookup
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
WorkflowDefinition or PipelineDefinition if found, None otherwise
|
|
258
|
+
"""
|
|
259
|
+
if ":" not in qualified_name:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
agent_name, workflow_name = qualified_name.split(":", 1)
|
|
263
|
+
|
|
264
|
+
# Import here to avoid circular imports
|
|
265
|
+
from gobby.agents.definitions import AgentDefinitionLoader
|
|
266
|
+
|
|
267
|
+
agent_loader = AgentDefinitionLoader()
|
|
268
|
+
agent_def = agent_loader.load(agent_name)
|
|
269
|
+
|
|
270
|
+
if not agent_def:
|
|
271
|
+
logger.debug(
|
|
272
|
+
f"Agent definition '{agent_name}' not found for workflow '{qualified_name}'"
|
|
273
|
+
)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
if not agent_def.workflows:
|
|
277
|
+
logger.debug(f"Agent '{agent_name}' has no workflows defined")
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
spec = agent_def.workflows.get(workflow_name)
|
|
281
|
+
if not spec:
|
|
282
|
+
logger.debug(f"Workflow '{workflow_name}' not found in agent '{agent_name}'")
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
# If it's a file reference, load from the file
|
|
286
|
+
if spec.is_file_reference():
|
|
287
|
+
file_name = spec.file or ""
|
|
288
|
+
# Remove .yaml extension if present for load_workflow call
|
|
289
|
+
workflow_file = file_name.removesuffix(".yaml")
|
|
290
|
+
logger.debug(
|
|
291
|
+
f"Loading file-referenced workflow '{workflow_file}' for '{qualified_name}'"
|
|
292
|
+
)
|
|
293
|
+
return self.load_workflow(workflow_file, project_path)
|
|
294
|
+
|
|
295
|
+
# It's an inline workflow - build definition from spec
|
|
296
|
+
if spec.is_inline():
|
|
297
|
+
return self._build_definition_from_spec(spec, qualified_name)
|
|
298
|
+
|
|
299
|
+
logger.debug(f"WorkflowSpec for '{qualified_name}' is neither file reference nor inline")
|
|
120
300
|
return None
|
|
121
301
|
|
|
302
|
+
def _build_definition_from_spec(
|
|
303
|
+
self,
|
|
304
|
+
spec: "WorkflowSpec",
|
|
305
|
+
name: str,
|
|
306
|
+
) -> WorkflowDefinition | PipelineDefinition:
|
|
307
|
+
"""
|
|
308
|
+
Build a WorkflowDefinition or PipelineDefinition from a WorkflowSpec.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
spec: The WorkflowSpec from an agent definition
|
|
312
|
+
name: The qualified workflow name (e.g., "meeseeks:worker")
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
WorkflowDefinition or PipelineDefinition
|
|
316
|
+
"""
|
|
317
|
+
# Convert spec to dict for definition creation
|
|
318
|
+
data = spec.model_dump(exclude_none=True, exclude_unset=True)
|
|
319
|
+
|
|
320
|
+
# Ensure name is set
|
|
321
|
+
if "name" not in data or data.get("name") is None:
|
|
322
|
+
data["name"] = name
|
|
323
|
+
|
|
324
|
+
# Remove 'file' field if present (it's not part of WorkflowDefinition)
|
|
325
|
+
data.pop("file", None)
|
|
326
|
+
|
|
327
|
+
# Default to step workflow if type not specified
|
|
328
|
+
if "type" not in data:
|
|
329
|
+
data["type"] = "step"
|
|
330
|
+
|
|
331
|
+
if data.get("type") == "pipeline":
|
|
332
|
+
self._validate_pipeline_references(data)
|
|
333
|
+
return PipelineDefinition(**data)
|
|
334
|
+
else:
|
|
335
|
+
return WorkflowDefinition(**data)
|
|
336
|
+
|
|
337
|
+
def _validate_pipeline_references(self, data: dict[str, Any]) -> None:
|
|
338
|
+
"""
|
|
339
|
+
Validate that all $step_id.output references in a pipeline refer to earlier steps.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
data: Pipeline data dictionary
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
ValueError: If a reference points to a non-existent or later step
|
|
346
|
+
"""
|
|
347
|
+
steps = data.get("steps", [])
|
|
348
|
+
step_ids = [s.get("id") for s in steps if s.get("id")]
|
|
349
|
+
|
|
350
|
+
# Build set of valid step IDs that can be referenced at each position
|
|
351
|
+
valid_at_position: dict[int, set[str]] = {}
|
|
352
|
+
for i in range(len(step_ids)):
|
|
353
|
+
# Steps at position i can only reference steps 0..i-1
|
|
354
|
+
valid_at_position[i] = set(step_ids[:i])
|
|
355
|
+
|
|
356
|
+
# Validate references in each step
|
|
357
|
+
for i, step in enumerate(steps):
|
|
358
|
+
step_id = step.get("id", f"step_{i}")
|
|
359
|
+
valid_refs = valid_at_position.get(i, set())
|
|
360
|
+
|
|
361
|
+
# Check prompt field
|
|
362
|
+
if "prompt" in step and step["prompt"]:
|
|
363
|
+
refs = self._extract_step_refs(step["prompt"])
|
|
364
|
+
self._check_refs(refs, valid_refs, step_ids, step_id, "prompt")
|
|
365
|
+
|
|
366
|
+
# Check condition field
|
|
367
|
+
if "condition" in step and step["condition"]:
|
|
368
|
+
refs = self._extract_step_refs(step["condition"])
|
|
369
|
+
self._check_refs(refs, valid_refs, step_ids, step_id, "condition")
|
|
370
|
+
|
|
371
|
+
# Check input field
|
|
372
|
+
if "input" in step and step["input"]:
|
|
373
|
+
refs = self._extract_step_refs(step["input"])
|
|
374
|
+
self._check_refs(refs, valid_refs, step_ids, step_id, "input")
|
|
375
|
+
|
|
376
|
+
# Check exec field (might have embedded references)
|
|
377
|
+
if "exec" in step and step["exec"]:
|
|
378
|
+
refs = self._extract_step_refs(step["exec"])
|
|
379
|
+
self._check_refs(refs, valid_refs, step_ids, step_id, "exec")
|
|
380
|
+
|
|
381
|
+
# Validate references in pipeline outputs (can reference any step)
|
|
382
|
+
all_step_ids = set(step_ids)
|
|
383
|
+
outputs = data.get("outputs", {})
|
|
384
|
+
for output_name, output_value in outputs.items():
|
|
385
|
+
if isinstance(output_value, str):
|
|
386
|
+
refs = self._extract_step_refs(output_value)
|
|
387
|
+
for ref in refs:
|
|
388
|
+
if ref not in all_step_ids:
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"Pipeline output '{output_name}' references unknown step '{ref}'. "
|
|
391
|
+
f"Valid steps: {sorted(all_step_ids)}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _extract_step_refs(self, text: str) -> set[str]:
|
|
395
|
+
"""
|
|
396
|
+
Extract step IDs from $step_id.output patterns in text.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
text: Text to search for references
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Set of step IDs referenced
|
|
403
|
+
"""
|
|
404
|
+
import re
|
|
405
|
+
|
|
406
|
+
# Match $step_id.output or $step_id.output.field patterns
|
|
407
|
+
# Exclude $inputs.* which are input references, not step references
|
|
408
|
+
pattern = r"\$([a-zA-Z_][a-zA-Z0-9_]*)\.(output|approved)"
|
|
409
|
+
matches = re.findall(pattern, text)
|
|
410
|
+
# Filter out 'inputs' which is a special reference
|
|
411
|
+
return {m[0] for m in matches if m[0] != "inputs"}
|
|
412
|
+
|
|
413
|
+
def _check_refs(
|
|
414
|
+
self,
|
|
415
|
+
refs: set[str],
|
|
416
|
+
valid_refs: set[str],
|
|
417
|
+
all_step_ids: list[str],
|
|
418
|
+
current_step: str,
|
|
419
|
+
field_name: str,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Check that all references are valid.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
refs: Set of referenced step IDs
|
|
426
|
+
valid_refs: Set of step IDs that can be referenced (earlier steps)
|
|
427
|
+
all_step_ids: List of all step IDs in the pipeline
|
|
428
|
+
current_step: Current step ID (for error messages)
|
|
429
|
+
field_name: Field name being checked (for error messages)
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
ValueError: If any reference is invalid
|
|
433
|
+
"""
|
|
434
|
+
for ref in refs:
|
|
435
|
+
if ref not in valid_refs:
|
|
436
|
+
if ref in all_step_ids:
|
|
437
|
+
# It's a forward reference
|
|
438
|
+
raise ValueError(
|
|
439
|
+
f"Step '{current_step}' {field_name} references step '{ref}' "
|
|
440
|
+
f"which appears later in the pipeline. Steps can only reference "
|
|
441
|
+
f"earlier steps. Valid references: {sorted(valid_refs) if valid_refs else '(none)'}"
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
# It's a non-existent step
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f"Step '{current_step}' {field_name} references unknown step '{ref}'. "
|
|
447
|
+
f"Valid steps: {sorted(all_step_ids)}"
|
|
448
|
+
)
|
|
449
|
+
|
|
122
450
|
def _merge_workflows(self, parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]:
|
|
123
451
|
"""
|
|
124
452
|
Deep merge parent and child workflow dicts.
|
|
@@ -142,22 +470,28 @@ class WorkflowLoader:
|
|
|
142
470
|
|
|
143
471
|
def _merge_steps(self, parent_steps: list[Any], child_steps: list[Any]) -> list[Any]:
|
|
144
472
|
"""
|
|
145
|
-
Merge step lists by step name.
|
|
473
|
+
Merge step lists by step name or id.
|
|
474
|
+
Supports both workflow steps (name key) and pipeline steps (id key).
|
|
146
475
|
"""
|
|
147
|
-
#
|
|
476
|
+
# Determine which key to use: 'id' for pipelines, 'name' for workflows
|
|
477
|
+
key_field = "id" if (parent_steps and "id" in parent_steps[0]) else "name"
|
|
478
|
+
if not parent_steps and child_steps:
|
|
479
|
+
key_field = "id" if "id" in child_steps[0] else "name"
|
|
480
|
+
|
|
481
|
+
# Convert parent list to dict by key, creating copies to avoid mutating originals
|
|
148
482
|
parent_map: dict[str, dict[str, Any]] = {}
|
|
149
483
|
for s in parent_steps:
|
|
150
|
-
if
|
|
151
|
-
logger.warning("Skipping parent step without '
|
|
484
|
+
if key_field not in s:
|
|
485
|
+
logger.warning(f"Skipping parent step without '{key_field}' key")
|
|
152
486
|
continue
|
|
153
487
|
# Create a shallow copy to avoid mutating the original
|
|
154
|
-
parent_map[s[
|
|
488
|
+
parent_map[s[key_field]] = dict(s)
|
|
155
489
|
|
|
156
490
|
for child_step in child_steps:
|
|
157
|
-
if
|
|
158
|
-
logger.warning("Skipping child step without '
|
|
491
|
+
if key_field not in child_step:
|
|
492
|
+
logger.warning(f"Skipping child step without '{key_field}' key")
|
|
159
493
|
continue
|
|
160
|
-
name = child_step[
|
|
494
|
+
name = child_step[key_field]
|
|
161
495
|
if name in parent_map:
|
|
162
496
|
# Merge existing step by updating the copy with child values
|
|
163
497
|
parent_map[name].update(child_step)
|
|
@@ -228,6 +562,150 @@ class WorkflowLoader:
|
|
|
228
562
|
self._discovery_cache[cache_key] = sorted_workflows
|
|
229
563
|
return sorted_workflows
|
|
230
564
|
|
|
565
|
+
def discover_pipeline_workflows(
|
|
566
|
+
self, project_path: Path | str | None = None
|
|
567
|
+
) -> list[DiscoveredWorkflow]:
|
|
568
|
+
"""
|
|
569
|
+
Discover all pipeline workflows from project and global directories.
|
|
570
|
+
|
|
571
|
+
Returns workflows sorted by:
|
|
572
|
+
1. Project workflows first (is_project=True), then global
|
|
573
|
+
2. Within each group: by priority (ascending), then alphabetically by name
|
|
574
|
+
|
|
575
|
+
Project workflows shadow global workflows with the same name.
|
|
576
|
+
|
|
577
|
+
Note: Unlike lifecycle workflows which are in lifecycle/ subdirs,
|
|
578
|
+
pipelines are in the root workflows/ directory.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
project_path: Optional project directory. If provided, searches
|
|
582
|
+
{project_path}/.gobby/workflows/ first.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
List of DiscoveredWorkflow objects with type='pipeline', sorted and deduplicated.
|
|
586
|
+
"""
|
|
587
|
+
cache_key = f"pipelines:{project_path}" if project_path else "pipelines:global"
|
|
588
|
+
|
|
589
|
+
# Check cache
|
|
590
|
+
if cache_key in self._discovery_cache:
|
|
591
|
+
return self._discovery_cache[cache_key]
|
|
592
|
+
|
|
593
|
+
discovered: dict[str, DiscoveredWorkflow] = {} # name -> workflow (for shadowing)
|
|
594
|
+
failed: dict[str, str] = {} # name -> error message for failed workflows
|
|
595
|
+
|
|
596
|
+
# 1. Scan global workflows directory first (will be shadowed by project)
|
|
597
|
+
for global_dir in self.global_dirs:
|
|
598
|
+
self._scan_pipeline_directory(global_dir, is_project=False, discovered=discovered)
|
|
599
|
+
|
|
600
|
+
# 2. Scan project workflows directory (shadows global)
|
|
601
|
+
if project_path:
|
|
602
|
+
project_dir = Path(project_path) / ".gobby" / "workflows"
|
|
603
|
+
self._scan_pipeline_directory(
|
|
604
|
+
project_dir, is_project=True, discovered=discovered, failed=failed
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# Log errors when project pipeline fails but global exists (failed shadowing)
|
|
608
|
+
for name, error in failed.items():
|
|
609
|
+
if name in discovered and not discovered[name].is_project:
|
|
610
|
+
logger.error(
|
|
611
|
+
f"Project pipeline '{name}' failed to load, using global instead: {error}"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
# 3. Sort: project first, then by priority (asc), then by name (alpha)
|
|
615
|
+
sorted_pipelines = sorted(
|
|
616
|
+
discovered.values(),
|
|
617
|
+
key=lambda w: (
|
|
618
|
+
0 if w.is_project else 1, # Project first
|
|
619
|
+
w.priority, # Lower priority = runs first
|
|
620
|
+
w.name, # Alphabetical
|
|
621
|
+
),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Cache and return
|
|
625
|
+
self._discovery_cache[cache_key] = sorted_pipelines
|
|
626
|
+
return sorted_pipelines
|
|
627
|
+
|
|
628
|
+
def _scan_pipeline_directory(
|
|
629
|
+
self,
|
|
630
|
+
directory: Path,
|
|
631
|
+
is_project: bool,
|
|
632
|
+
discovered: dict[str, DiscoveredWorkflow],
|
|
633
|
+
failed: dict[str, str] | None = None,
|
|
634
|
+
) -> None:
|
|
635
|
+
"""
|
|
636
|
+
Scan a directory for pipeline YAML files and add to discovered dict.
|
|
637
|
+
|
|
638
|
+
Only includes workflows with type='pipeline'.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
directory: Directory to scan
|
|
642
|
+
is_project: Whether this is a project directory (for shadowing)
|
|
643
|
+
discovered: Dict to update (name -> DiscoveredWorkflow)
|
|
644
|
+
failed: Optional dict to track failed pipelines (name -> error message)
|
|
645
|
+
"""
|
|
646
|
+
if not directory.exists():
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
for yaml_path in directory.glob("*.yaml"):
|
|
650
|
+
name = yaml_path.stem
|
|
651
|
+
try:
|
|
652
|
+
with open(yaml_path) as f:
|
|
653
|
+
data = yaml.safe_load(f)
|
|
654
|
+
|
|
655
|
+
if not data:
|
|
656
|
+
continue
|
|
657
|
+
|
|
658
|
+
# Only process pipeline type workflows
|
|
659
|
+
if data.get("type") != "pipeline":
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
# Handle inheritance with cycle detection
|
|
663
|
+
if "extends" in data:
|
|
664
|
+
parent_name = data["extends"]
|
|
665
|
+
try:
|
|
666
|
+
parent = self.load_pipeline(
|
|
667
|
+
parent_name,
|
|
668
|
+
_inheritance_chain=[name],
|
|
669
|
+
)
|
|
670
|
+
if parent:
|
|
671
|
+
data = self._merge_workflows(parent.model_dump(), data)
|
|
672
|
+
except ValueError as e:
|
|
673
|
+
logger.warning(f"Skipping pipeline {name}: {e}")
|
|
674
|
+
if failed is not None:
|
|
675
|
+
failed[name] = str(e)
|
|
676
|
+
continue
|
|
677
|
+
|
|
678
|
+
# Validate references before creating definition
|
|
679
|
+
self._validate_pipeline_references(data)
|
|
680
|
+
|
|
681
|
+
definition = PipelineDefinition(**data)
|
|
682
|
+
|
|
683
|
+
# Get priority from data settings or default to 100
|
|
684
|
+
# (PipelineDefinition doesn't have settings field, use raw data)
|
|
685
|
+
priority = 100
|
|
686
|
+
settings = data.get("settings", {})
|
|
687
|
+
if settings and "priority" in settings:
|
|
688
|
+
priority = settings["priority"]
|
|
689
|
+
|
|
690
|
+
# Log successful shadowing when project pipeline overrides global
|
|
691
|
+
if name in discovered and is_project and not discovered[name].is_project:
|
|
692
|
+
logger.info(f"Project pipeline '{name}' shadows global pipeline")
|
|
693
|
+
|
|
694
|
+
# Project pipelines shadow global (overwrite in dict)
|
|
695
|
+
# Global is scanned first, so project overwrites
|
|
696
|
+
discovered[name] = DiscoveredWorkflow(
|
|
697
|
+
name=name,
|
|
698
|
+
definition=definition,
|
|
699
|
+
priority=priority,
|
|
700
|
+
is_project=is_project,
|
|
701
|
+
path=yaml_path,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
except Exception as e:
|
|
705
|
+
logger.warning(f"Failed to load pipeline from {yaml_path}: {e}")
|
|
706
|
+
if failed is not None:
|
|
707
|
+
failed[name] = str(e)
|
|
708
|
+
|
|
231
709
|
def _scan_directory(
|
|
232
710
|
self,
|
|
233
711
|
directory: Path,
|
|
@@ -306,6 +784,65 @@ class WorkflowLoader:
|
|
|
306
784
|
self._cache.clear()
|
|
307
785
|
self._discovery_cache.clear()
|
|
308
786
|
|
|
787
|
+
def register_inline_workflow(
|
|
788
|
+
self,
|
|
789
|
+
name: str,
|
|
790
|
+
data: dict[str, Any],
|
|
791
|
+
project_path: Path | str | None = None,
|
|
792
|
+
) -> WorkflowDefinition | PipelineDefinition:
|
|
793
|
+
"""
|
|
794
|
+
Register an inline workflow definition in the cache.
|
|
795
|
+
|
|
796
|
+
Inline workflows are embedded in agent definitions and registered
|
|
797
|
+
at spawn time with qualified names like "agent:workflow".
|
|
798
|
+
|
|
799
|
+
Note: Inline workflows are NOT written to disk. Child agents can load
|
|
800
|
+
them directly from agent definitions via load_workflow() which handles
|
|
801
|
+
qualified names (agent:workflow) by parsing the agent YAML.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
name: Qualified workflow name (e.g., "meeseeks:worker")
|
|
805
|
+
data: Workflow definition data dict
|
|
806
|
+
project_path: Project path for cache key scoping
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
The created WorkflowDefinition or PipelineDefinition
|
|
810
|
+
|
|
811
|
+
Raises:
|
|
812
|
+
ValueError: If the workflow definition is invalid
|
|
813
|
+
"""
|
|
814
|
+
cache_key = f"{project_path or 'global'}:{name}"
|
|
815
|
+
|
|
816
|
+
# Already registered?
|
|
817
|
+
if cache_key in self._cache:
|
|
818
|
+
cached = self._cache[cache_key]
|
|
819
|
+
if isinstance(cached, (WorkflowDefinition, PipelineDefinition)):
|
|
820
|
+
return cached
|
|
821
|
+
|
|
822
|
+
# Ensure name is set in data (handle both missing and None)
|
|
823
|
+
if "name" not in data or data.get("name") is None:
|
|
824
|
+
data["name"] = name
|
|
825
|
+
|
|
826
|
+
# Create definition based on type
|
|
827
|
+
try:
|
|
828
|
+
if data.get("type") == "pipeline":
|
|
829
|
+
self._validate_pipeline_references(data)
|
|
830
|
+
definition: WorkflowDefinition | PipelineDefinition = PipelineDefinition(**data)
|
|
831
|
+
else:
|
|
832
|
+
# Default to step workflow
|
|
833
|
+
if "type" not in data:
|
|
834
|
+
data["type"] = "step"
|
|
835
|
+
definition = WorkflowDefinition(**data)
|
|
836
|
+
|
|
837
|
+
self._cache[cache_key] = definition
|
|
838
|
+
|
|
839
|
+
logger.debug(f"Registered inline workflow '{name}' (type={definition.type})")
|
|
840
|
+
return definition
|
|
841
|
+
|
|
842
|
+
except Exception as e:
|
|
843
|
+
logger.error(f"Failed to register inline workflow '{name}': {e}")
|
|
844
|
+
raise ValueError(f"Invalid inline workflow '{name}': {e}") from e
|
|
845
|
+
|
|
309
846
|
def validate_workflow_for_agent(
|
|
310
847
|
self,
|
|
311
848
|
workflow_name: str,
|