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.
Files changed (156) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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
+ ]