forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""The Stage interface, StageContext, and ExecutionEngine.
|
|
2
|
+
|
|
3
|
+
A :class:`Stage` is the unit of orchestration. Every stage is a
|
|
4
|
+
small async callable that takes a :class:`StageContext` and returns
|
|
5
|
+
a :class:`StageResult`. Stages are *independently replaceable* — a
|
|
6
|
+
plugin can drop in a custom stage that supersedes the default one
|
|
7
|
+
under the same name.
|
|
8
|
+
|
|
9
|
+
The :class:`ExecutionEngine` runs the registered stages in order,
|
|
10
|
+
emits structured events, supports retries, cancellation, and a
|
|
11
|
+
small lifecycle:
|
|
12
|
+
|
|
13
|
+
* StageStart -> stage.run() -> StageEnd
|
|
14
|
+
* On exception: schedule a retry up to ``max_attempts``; on
|
|
15
|
+
exhaustion mark the run failed.
|
|
16
|
+
* On cancellation: raise :class:`EngineCancelled` immediately.
|
|
17
|
+
|
|
18
|
+
The engine does *no* business logic. Stages encapsulate the work.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import inspect
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
from collections.abc import Iterable
|
|
29
|
+
from dataclasses import dataclass, field, replace
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from typing import Any, Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
from forgecli.engine.context import EngineContext, StageLog
|
|
34
|
+
from forgecli.engine.events import (
|
|
35
|
+
EventBus,
|
|
36
|
+
LogLevel,
|
|
37
|
+
ProgressEvent,
|
|
38
|
+
StageEvent,
|
|
39
|
+
TextLogEvent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StageStatus(str, Enum):
|
|
44
|
+
"""Per-stage lifecycle status."""
|
|
45
|
+
|
|
46
|
+
PENDING = "pending"
|
|
47
|
+
RUNNING = "running"
|
|
48
|
+
SUCCEEDED = "succeeded"
|
|
49
|
+
FAILED = "failed"
|
|
50
|
+
SKIPPED = "skipped"
|
|
51
|
+
RETRYING = "retrying"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class StageResult:
|
|
56
|
+
"""Structured output of a :class:`Stage`.
|
|
57
|
+
|
|
58
|
+
Every stage returns one of these. The ``data`` dict is a free-form
|
|
59
|
+
bag of structured output (typed per stage); ``notes`` are
|
|
60
|
+
human-readable summary lines; ``error`` is set on failure.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
status: StageStatus
|
|
64
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
notes: tuple[str, ...] = ()
|
|
66
|
+
error: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class StageContext:
|
|
71
|
+
"""Per-stage runtime context.
|
|
72
|
+
|
|
73
|
+
The engine hands each stage a :class:`StageContext` that bundles
|
|
74
|
+
the shared :class:`EngineContext` with the active
|
|
75
|
+
:class:`EventBus`, the current attempt number, and a helper
|
|
76
|
+
for emitting progress / log events.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
engine: EngineContext
|
|
80
|
+
bus: EventBus
|
|
81
|
+
attempt: int = 1
|
|
82
|
+
max_attempts: int = 1
|
|
83
|
+
|
|
84
|
+
def log(self, message: str, *, level: LogLevel = LogLevel.INFO, source: str = "") -> None:
|
|
85
|
+
self.bus.publish(
|
|
86
|
+
TextLogEvent(
|
|
87
|
+
level=level,
|
|
88
|
+
source=source or self.engine.extras.get("stage_name", ""),
|
|
89
|
+
message=message,
|
|
90
|
+
run_id=self.engine.run_id,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def progress(self, value: float, *, message: str | None = None) -> None:
|
|
95
|
+
self.bus.publish(
|
|
96
|
+
ProgressEvent(
|
|
97
|
+
stage=self.engine.extras.get("stage_name", ""),
|
|
98
|
+
progress=max(0.0, min(1.0, value)),
|
|
99
|
+
message=message,
|
|
100
|
+
run_id=self.engine.run_id,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def cancelled(self) -> bool:
|
|
105
|
+
return self.bus.is_cancelled()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@runtime_checkable
|
|
109
|
+
class Stage(Protocol):
|
|
110
|
+
"""The protocol every engine stage must implement.
|
|
111
|
+
|
|
112
|
+
A Stage is *just* an async callable that takes a
|
|
113
|
+
:class:`StageContext` and returns a :class:`StageResult`. The
|
|
114
|
+
name (``name``) is what shows up in events and logs. Stages
|
|
115
|
+
are normally registered by name so plugins can replace them
|
|
116
|
+
individually.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
name: str
|
|
120
|
+
|
|
121
|
+
async def __call__(self, context: StageContext) -> StageResult: ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Convenience base class
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BaseStage(ABC):
|
|
130
|
+
"""A small ABC for stages that prefer the class form.
|
|
131
|
+
|
|
132
|
+
The engine accepts both :class:`Stage` callables and
|
|
133
|
+
:class:`BaseStage` instances. Subclasses must set ``name`` and
|
|
134
|
+
implement :meth:`run`.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
name: str = "abstract"
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
async def run(self, context: StageContext) -> StageResult:
|
|
141
|
+
"""Execute the stage and return a :class:`StageResult`."""
|
|
142
|
+
|
|
143
|
+
async def __call__(self, context: StageContext) -> StageResult:
|
|
144
|
+
return await self.run(context)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Default stage implementations (no business logic — only contracts)
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _NoOpStage(BaseStage):
|
|
153
|
+
"""A pass-through stage that does nothing. Useful for tests."""
|
|
154
|
+
|
|
155
|
+
name = "noop"
|
|
156
|
+
|
|
157
|
+
async def run(self, context: StageContext) -> StageResult:
|
|
158
|
+
return StageResult(status=StageStatus.SUCCEEDED, notes=("no-op",))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Result of a run
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class EngineResult:
|
|
168
|
+
"""The result of a complete engine run."""
|
|
169
|
+
|
|
170
|
+
success: bool
|
|
171
|
+
context: EngineContext
|
|
172
|
+
stage_results: list[StageResult]
|
|
173
|
+
failed_stage: str | None = None
|
|
174
|
+
cancelled: bool = False
|
|
175
|
+
error: str | None = None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Pipeline builder
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class PipelineBuilder:
|
|
185
|
+
"""Fluent builder for an :class:`ExecutionEngine` instance."""
|
|
186
|
+
|
|
187
|
+
bus: EventBus = field(default_factory=EventBus) # type: ignore[assignment]
|
|
188
|
+
stages: list[Stage] = field(default_factory=list)
|
|
189
|
+
max_attempts_per_stage: int = 1
|
|
190
|
+
retry_backoff_seconds: float = 0.5
|
|
191
|
+
|
|
192
|
+
def stage(self, stage: Stage) -> PipelineBuilder:
|
|
193
|
+
self.stages.append(stage)
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def stages_from(
|
|
197
|
+
self, names: Iterable[str], registry: StageRegistry
|
|
198
|
+
) -> PipelineBuilder:
|
|
199
|
+
for name in names:
|
|
200
|
+
stage = registry.get(name)
|
|
201
|
+
self.stages.append(stage)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def with_max_attempts(self, attempts: int) -> PipelineBuilder:
|
|
205
|
+
self.max_attempts_per_stage = max(1, attempts)
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def with_retry_backoff(self, seconds: float) -> PipelineBuilder:
|
|
209
|
+
self.retry_backoff_seconds = max(0.0, seconds)
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def build(self) -> ExecutionEngine:
|
|
213
|
+
return ExecutionEngine(
|
|
214
|
+
stages=self.stages,
|
|
215
|
+
bus=self.bus,
|
|
216
|
+
max_attempts_per_stage=self.max_attempts_per_stage,
|
|
217
|
+
retry_backoff_seconds=self.retry_backoff_seconds,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Stage registry
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class StageRegistry:
|
|
227
|
+
"""A name -> Stage mapping.
|
|
228
|
+
|
|
229
|
+
Plugins and core ship stages to this registry; the engine looks
|
|
230
|
+
them up by name when assembling a pipeline. A registry can be
|
|
231
|
+
populated directly, by entry-point plugin, or by calling
|
|
232
|
+
:meth:`register`.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self) -> None:
|
|
236
|
+
self._stages: dict[str, Stage] = {}
|
|
237
|
+
|
|
238
|
+
def register(self, stage: Stage) -> None:
|
|
239
|
+
if not stage.name:
|
|
240
|
+
raise ValueError("Stage.name must be non-empty")
|
|
241
|
+
if stage.name in self._stages:
|
|
242
|
+
raise ValueError(f"Stage {stage.name!r} already registered")
|
|
243
|
+
self._stages[stage.name] = stage
|
|
244
|
+
|
|
245
|
+
def replace(self, stage: Stage) -> None:
|
|
246
|
+
if not stage.name:
|
|
247
|
+
raise ValueError("Stage.name must be non-empty")
|
|
248
|
+
self._stages[stage.name] = stage
|
|
249
|
+
|
|
250
|
+
def get(self, name: str) -> Stage:
|
|
251
|
+
if name not in self._stages:
|
|
252
|
+
raise KeyError(f"No stage registered under {name!r}")
|
|
253
|
+
return self._stages[name]
|
|
254
|
+
|
|
255
|
+
def has(self, name: str) -> bool:
|
|
256
|
+
return name in self._stages
|
|
257
|
+
|
|
258
|
+
def names(self) -> list[str]:
|
|
259
|
+
return list(self._stages)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# ExecutionEngine
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ExecutionEngine:
|
|
268
|
+
"""The async orchestration runtime.
|
|
269
|
+
|
|
270
|
+
The engine does no business logic. It runs the registered
|
|
271
|
+
stages in order, emitting structured events, retrying failed
|
|
272
|
+
stages up to ``max_attempts_per_stage`` with a backoff, and
|
|
273
|
+
honouring a cancellation token between stages.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
DEFAULT_PIPELINE: tuple[str, ...] = (
|
|
277
|
+
"intent-analyzer",
|
|
278
|
+
"repository-analyzer",
|
|
279
|
+
"caveman-optimizer",
|
|
280
|
+
"context-optimizer",
|
|
281
|
+
"planning-engine",
|
|
282
|
+
"model-router",
|
|
283
|
+
"execution-engine",
|
|
284
|
+
"validation-engine",
|
|
285
|
+
"git-engine",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
stages: Iterable[Stage] | None = None,
|
|
291
|
+
*,
|
|
292
|
+
bus: EventBus | None = None,
|
|
293
|
+
max_attempts_per_stage: int = 1,
|
|
294
|
+
retry_backoff_seconds: float = 0.5,
|
|
295
|
+
) -> None:
|
|
296
|
+
self.bus: EventBus = bus or EventBus()
|
|
297
|
+
self._stages: list[Stage] = list(stages or [])
|
|
298
|
+
self._max_attempts = max(1, max_attempts_per_stage)
|
|
299
|
+
self._retry_backoff = max(0.0, retry_backoff_seconds)
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def from_registry(
|
|
303
|
+
cls,
|
|
304
|
+
registry: StageRegistry,
|
|
305
|
+
*,
|
|
306
|
+
names: Iterable[str] | None = None,
|
|
307
|
+
bus: EventBus | None = None,
|
|
308
|
+
max_attempts_per_stage: int = 1,
|
|
309
|
+
retry_backoff_seconds: float = 0.5,
|
|
310
|
+
) -> ExecutionEngine:
|
|
311
|
+
names = tuple(names or cls.DEFAULT_PIPELINE)
|
|
312
|
+
stages = [registry.get(name) for name in names]
|
|
313
|
+
return cls(
|
|
314
|
+
stages=stages,
|
|
315
|
+
bus=bus,
|
|
316
|
+
max_attempts_per_stage=max_attempts_per_stage,
|
|
317
|
+
retry_backoff_seconds=retry_backoff_seconds,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def stages(self) -> list[Stage]:
|
|
322
|
+
return list(self._stages)
|
|
323
|
+
|
|
324
|
+
async def run(self, context: EngineContext) -> EngineResult:
|
|
325
|
+
"""Run every stage in order; return a structured :class:`EngineResult`."""
|
|
326
|
+
if not self.bus.is_cancelled():
|
|
327
|
+
self.bus.reset_cancellation()
|
|
328
|
+
results: list[StageResult] = []
|
|
329
|
+
|
|
330
|
+
for stage in self._stages:
|
|
331
|
+
if self.bus.is_cancelled():
|
|
332
|
+
self.bus.publish(
|
|
333
|
+
TextLogEvent(
|
|
334
|
+
level=LogLevel.WARN,
|
|
335
|
+
source=stage.name,
|
|
336
|
+
message="engine cancelled before stage",
|
|
337
|
+
run_id=context.run_id,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
return EngineResult(
|
|
341
|
+
success=False,
|
|
342
|
+
context=context,
|
|
343
|
+
stage_results=results,
|
|
344
|
+
cancelled=True,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
stage_result = await self._run_stage(stage, context)
|
|
348
|
+
results.append(stage_result)
|
|
349
|
+
if stage_result.status is StageStatus.FAILED:
|
|
350
|
+
return EngineResult(
|
|
351
|
+
success=False,
|
|
352
|
+
context=context,
|
|
353
|
+
stage_results=results,
|
|
354
|
+
failed_stage=stage.name,
|
|
355
|
+
error=stage_result.error,
|
|
356
|
+
)
|
|
357
|
+
return EngineResult(
|
|
358
|
+
success=True,
|
|
359
|
+
context=context,
|
|
360
|
+
stage_results=results,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# Internals
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
async def _run_stage(
|
|
368
|
+
self, stage: Stage, context: EngineContext
|
|
369
|
+
) -> StageResult:
|
|
370
|
+
stage_context = StageContext(
|
|
371
|
+
engine=context,
|
|
372
|
+
bus=self.bus,
|
|
373
|
+
attempt=1,
|
|
374
|
+
max_attempts=self._max_attempts,
|
|
375
|
+
)
|
|
376
|
+
# Make the active stage name visible to the stage via extras
|
|
377
|
+
# so log()/progress() can label themselves.
|
|
378
|
+
context.extras["stage_name"] = stage.name
|
|
379
|
+
|
|
380
|
+
log = StageLog(stage=stage.name, status="running", started_at=time.time())
|
|
381
|
+
context.log.append(log)
|
|
382
|
+
|
|
383
|
+
self.bus.publish(
|
|
384
|
+
StageEvent(
|
|
385
|
+
stage=stage.name,
|
|
386
|
+
status="running",
|
|
387
|
+
attempt=1,
|
|
388
|
+
run_id=context.run_id,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
last_exc: BaseException | None = None
|
|
393
|
+
for attempt in range(1, self._max_attempts + 1):
|
|
394
|
+
stage_context.attempt = attempt
|
|
395
|
+
try:
|
|
396
|
+
result = await self._invoke(stage, stage_context)
|
|
397
|
+
except asyncio.CancelledError:
|
|
398
|
+
log = replace(
|
|
399
|
+
log,
|
|
400
|
+
status="failed",
|
|
401
|
+
error="cancelled",
|
|
402
|
+
finished_at=time.time(),
|
|
403
|
+
)
|
|
404
|
+
context.log[-1] = log
|
|
405
|
+
self.bus.publish(
|
|
406
|
+
StageEvent(
|
|
407
|
+
stage=stage.name,
|
|
408
|
+
status="failed",
|
|
409
|
+
attempt=attempt,
|
|
410
|
+
note="cancelled",
|
|
411
|
+
run_id=context.run_id,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return StageResult(
|
|
415
|
+
status=StageStatus.FAILED,
|
|
416
|
+
notes=("cancelled",),
|
|
417
|
+
error="cancelled",
|
|
418
|
+
)
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
last_exc = exc
|
|
421
|
+
self.bus.publish(
|
|
422
|
+
TextLogEvent(
|
|
423
|
+
level=LogLevel.ERROR,
|
|
424
|
+
source=stage.name,
|
|
425
|
+
message=f"stage failed: {exc}",
|
|
426
|
+
run_id=context.run_id,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
if attempt >= self._max_attempts:
|
|
430
|
+
break
|
|
431
|
+
self.bus.publish(
|
|
432
|
+
StageEvent(
|
|
433
|
+
stage=stage.name,
|
|
434
|
+
status="retrying",
|
|
435
|
+
attempt=attempt + 1,
|
|
436
|
+
note=str(exc),
|
|
437
|
+
run_id=context.run_id,
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
if self._retry_backoff > 0:
|
|
441
|
+
await asyncio.sleep(self._retry_backoff * attempt)
|
|
442
|
+
continue
|
|
443
|
+
else:
|
|
444
|
+
log = replace(
|
|
445
|
+
log,
|
|
446
|
+
status="skipped" if result.status is StageStatus.SKIPPED else "succeeded",
|
|
447
|
+
finished_at=time.time(),
|
|
448
|
+
notes=result.notes,
|
|
449
|
+
)
|
|
450
|
+
context.log[-1] = log
|
|
451
|
+
self.bus.publish(
|
|
452
|
+
StageEvent(
|
|
453
|
+
stage=stage.name,
|
|
454
|
+
status=log.status,
|
|
455
|
+
attempt=attempt,
|
|
456
|
+
run_id=context.run_id,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
# All retries exhausted.
|
|
462
|
+
log = replace(
|
|
463
|
+
log,
|
|
464
|
+
status="failed",
|
|
465
|
+
finished_at=time.time(),
|
|
466
|
+
error=repr(last_exc) if last_exc else "unknown error",
|
|
467
|
+
)
|
|
468
|
+
context.log[-1] = log
|
|
469
|
+
self.bus.publish(
|
|
470
|
+
StageEvent(
|
|
471
|
+
stage=stage.name,
|
|
472
|
+
status="failed",
|
|
473
|
+
attempt=self._max_attempts,
|
|
474
|
+
note=log.error,
|
|
475
|
+
run_id=context.run_id,
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
return StageResult(
|
|
479
|
+
status=StageStatus.FAILED,
|
|
480
|
+
notes=("retries exhausted",),
|
|
481
|
+
error=log.error,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
async def _invoke(
|
|
485
|
+
self, stage: Stage, stage_context: StageContext
|
|
486
|
+
) -> StageResult:
|
|
487
|
+
"""Run a single stage invocation.
|
|
488
|
+
|
|
489
|
+
Handles both async callables and sync callables. Surfaces
|
|
490
|
+
unexpected exceptions to the event bus before propagating.
|
|
491
|
+
"""
|
|
492
|
+
try:
|
|
493
|
+
result = stage(stage_context)
|
|
494
|
+
if inspect.isawaitable(result):
|
|
495
|
+
return await result
|
|
496
|
+
if asyncio.iscoroutine(result):
|
|
497
|
+
return await result
|
|
498
|
+
# Stage returned synchronously: that's a programmer error
|
|
499
|
+
# (every engine stage is async). Surface it cleanly.
|
|
500
|
+
raise TypeError(
|
|
501
|
+
f"Stage {stage.name!r} did not return an awaitable; "
|
|
502
|
+
"engine stages must be async."
|
|
503
|
+
)
|
|
504
|
+
except Exception:
|
|
505
|
+
self.bus.publish(
|
|
506
|
+
TextLogEvent(
|
|
507
|
+
level=LogLevel.ERROR,
|
|
508
|
+
source=stage.name,
|
|
509
|
+
message=traceback.format_exc(),
|
|
510
|
+
run_id=stage_context.engine.run_id,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
raise
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
__all__ = [
|
|
517
|
+
"BaseStage",
|
|
518
|
+
"EngineResult",
|
|
519
|
+
"ExecutionEngine",
|
|
520
|
+
"PipelineBuilder",
|
|
521
|
+
"Stage",
|
|
522
|
+
"StageContext",
|
|
523
|
+
"StageRegistry",
|
|
524
|
+
"StageResult",
|
|
525
|
+
"StageStatus",
|
|
526
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Plugin hooks for the Execution Engine.
|
|
2
|
+
|
|
3
|
+
Plugins extend the engine by:
|
|
4
|
+
|
|
5
|
+
* registering new :class:`~forgecli.engine.execution.Stage`
|
|
6
|
+
implementations;
|
|
7
|
+
* adding event subscribers (for telemetry, custom sinks, etc.);
|
|
8
|
+
* registering themselves with the global :class:`PluginRegistry`.
|
|
9
|
+
|
|
10
|
+
Two hook points are provided:
|
|
11
|
+
|
|
12
|
+
* ``before_pipeline(context)`` — runs before the first stage.
|
|
13
|
+
* ``after_pipeline(result)`` — runs after the last stage (or on
|
|
14
|
+
failure / cancellation).
|
|
15
|
+
|
|
16
|
+
Plugins are also :class:`Stage` instances themselves, so a plugin
|
|
17
|
+
can drop in a single stage that replaces a default stage by name.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from forgecli.engine.execution import EngineResult, Stage
|
|
27
|
+
from forgecli.plugins import PluginRegistry
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
30
|
+
from forgecli.engine.context import EngineContext
|
|
31
|
+
from forgecli.engine.events import EventBus
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# A plugin is anything callable that takes a PluginRegistry.
|
|
35
|
+
EnginePluginFactory = Callable[[PluginRegistry], None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PluginHook:
|
|
40
|
+
"""A single async lifecycle hook fired by the engine.
|
|
41
|
+
|
|
42
|
+
Hooks fire in the order they were registered. A hook that
|
|
43
|
+
raises is logged on the event bus but does *not* abort the
|
|
44
|
+
pipeline — plugins must not break the engine.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
callback: Callable[[], Awaitable[None] | None]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Registration helper
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_plugin(
|
|
57
|
+
plugin_factory: EnginePluginFactory,
|
|
58
|
+
*,
|
|
59
|
+
registry: PluginRegistry,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Register a plugin with the given :class:`PluginRegistry`."""
|
|
62
|
+
plugin_factory(registry)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Hook manager (per-engine)
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HookManager:
|
|
71
|
+
"""Holds the before/after hooks for a single engine instance."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._before: list[PluginHook] = []
|
|
75
|
+
self._after: list[PluginHook] = []
|
|
76
|
+
|
|
77
|
+
def add_before(self, hook: PluginHook) -> None:
|
|
78
|
+
self._before.append(hook)
|
|
79
|
+
|
|
80
|
+
def add_after(self, hook: PluginHook) -> None:
|
|
81
|
+
self._after.append(hook)
|
|
82
|
+
|
|
83
|
+
async def fire_before(self, context: EngineContext, bus: EventBus) -> None:
|
|
84
|
+
from forgecli.engine.events import LogLevel, TextLogEvent
|
|
85
|
+
|
|
86
|
+
for hook in self._before:
|
|
87
|
+
try:
|
|
88
|
+
result = hook.callback()
|
|
89
|
+
if result is not None:
|
|
90
|
+
await result
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
bus.publish(
|
|
93
|
+
TextLogEvent(
|
|
94
|
+
level=LogLevel.WARN,
|
|
95
|
+
source=f"plugin:{hook.name}",
|
|
96
|
+
message=f"before-pipeline hook failed: {exc}",
|
|
97
|
+
run_id=context.run_id,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def fire_after(
|
|
102
|
+
self, result: EngineResult, bus: EventBus
|
|
103
|
+
) -> None:
|
|
104
|
+
from forgecli.engine.events import LogLevel, TextLogEvent
|
|
105
|
+
|
|
106
|
+
for hook in self._after:
|
|
107
|
+
try:
|
|
108
|
+
value = hook.callback()
|
|
109
|
+
if value is not None:
|
|
110
|
+
await value
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
bus.publish(
|
|
113
|
+
TextLogEvent(
|
|
114
|
+
level=LogLevel.WARN,
|
|
115
|
+
source=f"plugin:{hook.name}",
|
|
116
|
+
message=f"after-pipeline hook failed: {exc}",
|
|
117
|
+
run_id=result.context.run_id,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Stage-level plugin helper
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def stage_as_plugin(stage: Stage) -> EnginePluginFactory:
|
|
128
|
+
"""Wrap a :class:`Stage` as a plugin factory that auto-registers
|
|
129
|
+
itself in any :class:`PluginRegistry`.
|
|
130
|
+
|
|
131
|
+
The plugin is registered under the stage's name; calling it
|
|
132
|
+
again replaces the previous binding (last writer wins).
|
|
133
|
+
"""
|
|
134
|
+
def _factory(registry: PluginRegistry) -> None:
|
|
135
|
+
registry.register_stage(stage) # type: ignore[attr-defined]
|
|
136
|
+
|
|
137
|
+
return _factory
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"EnginePluginFactory",
|
|
142
|
+
"HookManager",
|
|
143
|
+
"PluginHook",
|
|
144
|
+
"register_plugin",
|
|
145
|
+
"stage_as_plugin",
|
|
146
|
+
]
|