forgex-cli 0.1.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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/models/router.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Model router (spec sections 13, 14, 30, 31).
|
|
2
|
+
|
|
3
|
+
Agents ask the router — never a provider SDK. The router:
|
|
4
|
+
* resolves the provider+model binding for an agent role from config
|
|
5
|
+
* adapts requests to model capabilities (drops unsupported fields)
|
|
6
|
+
* applies provider fallback (spec 30) after retries are exhausted
|
|
7
|
+
* records usage for observability (spec sections 40, 41)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import AsyncIterator, Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from forgex.config.schema import ForgexConfig, ProviderConfig
|
|
20
|
+
from forgex.models.base import (
|
|
21
|
+
Message,
|
|
22
|
+
ModelProvider,
|
|
23
|
+
ModelRequest,
|
|
24
|
+
ModelResponse,
|
|
25
|
+
ProviderHealth,
|
|
26
|
+
)
|
|
27
|
+
from forgex.models.capabilities import ModelCapabilities
|
|
28
|
+
from forgex.models.factory import build_provider
|
|
29
|
+
from forgex.models.registry import ModelRegistry
|
|
30
|
+
from forgex.schemas.error import ConfigError, ProviderError
|
|
31
|
+
from forgex.schemas.result import ModelUsageRecord
|
|
32
|
+
from forgex.storage.repositories import ModelUsageRepository
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("forgex.models.router")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ModelBinding:
|
|
39
|
+
agent: str
|
|
40
|
+
provider_name: str
|
|
41
|
+
model: str
|
|
42
|
+
provider: ModelProvider
|
|
43
|
+
capabilities: ModelCapabilities
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModelRouter:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
config: ForgexConfig,
|
|
50
|
+
*,
|
|
51
|
+
usage: ModelUsageRepository | None = None,
|
|
52
|
+
provider_builder: Callable[[str, ProviderConfig], ModelProvider] = build_provider,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._config = config
|
|
55
|
+
self._usage = usage
|
|
56
|
+
self._builder = provider_builder
|
|
57
|
+
self._registry = ModelRegistry(config.models.capabilities)
|
|
58
|
+
self._providers: dict[str, ModelProvider] = {}
|
|
59
|
+
|
|
60
|
+
def _provider(self, name: str) -> ModelProvider:
|
|
61
|
+
if name not in self._providers:
|
|
62
|
+
cfg = self._config.providers.get(name)
|
|
63
|
+
if cfg is None:
|
|
64
|
+
raise ConfigError(f"Provider '{name}' not found in configuration")
|
|
65
|
+
self._providers[name] = self._builder(name, cfg)
|
|
66
|
+
return self._providers[name]
|
|
67
|
+
|
|
68
|
+
def _resolve_binding(self, agent: str) -> ModelBinding:
|
|
69
|
+
models_cfg = self._config.models
|
|
70
|
+
binding_cfg = getattr(models_cfg, agent, None)
|
|
71
|
+
if binding_cfg is None:
|
|
72
|
+
raise ConfigError(f"No model binding configured for agent role '{agent}'")
|
|
73
|
+
|
|
74
|
+
prov_name = binding_cfg.provider
|
|
75
|
+
prov_cfg = self._config.providers.get(prov_name)
|
|
76
|
+
if prov_cfg is None:
|
|
77
|
+
raise ConfigError(f"Provider '{prov_name}' specified for '{agent}' does not exist")
|
|
78
|
+
|
|
79
|
+
model_name = binding_cfg.model or prov_cfg.default_model
|
|
80
|
+
if not model_name:
|
|
81
|
+
raise ConfigError(f"No model specified for agent '{agent}' and provider '{prov_name}' has no default_model")
|
|
82
|
+
|
|
83
|
+
caps = self._registry.lookup(model_name)
|
|
84
|
+
return ModelBinding(
|
|
85
|
+
agent=agent,
|
|
86
|
+
provider_name=prov_name,
|
|
87
|
+
model=model_name,
|
|
88
|
+
provider=self._provider(prov_name),
|
|
89
|
+
capabilities=caps,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def binding_for(self, provider: str, model: str) -> ModelBinding:
|
|
93
|
+
cfg = self._config.providers.get(provider)
|
|
94
|
+
if cfg is None:
|
|
95
|
+
raise ConfigError(f"Unknown provider '{provider}' — check [providers.{provider}] in config")
|
|
96
|
+
resolved_model = model or cfg.default_model
|
|
97
|
+
if not resolved_model:
|
|
98
|
+
raise ConfigError(f"No model for explicit binding on provider '{provider}'")
|
|
99
|
+
return ModelBinding(
|
|
100
|
+
agent="explicit",
|
|
101
|
+
provider_name=provider,
|
|
102
|
+
model=resolved_model,
|
|
103
|
+
provider=self._provider(provider),
|
|
104
|
+
capabilities=self._registry.lookup(resolved_model),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def capabilities_for(self, agent: str) -> ModelCapabilities:
|
|
108
|
+
return self._resolve_binding(agent).capabilities
|
|
109
|
+
|
|
110
|
+
def capabilities_for_binding(self, provider: str, model: str) -> ModelCapabilities:
|
|
111
|
+
return self.binding_for(provider, model).capabilities
|
|
112
|
+
|
|
113
|
+
def _build_request(
|
|
114
|
+
self,
|
|
115
|
+
binding: ModelBinding,
|
|
116
|
+
messages: list[Message],
|
|
117
|
+
*,
|
|
118
|
+
system: str | None = None,
|
|
119
|
+
tools: list[dict[str, Any]] | None = None,
|
|
120
|
+
tool_choice: str | None = None,
|
|
121
|
+
response_format: dict[str, str] | None = None,
|
|
122
|
+
temperature: float | None = None,
|
|
123
|
+
max_tokens: int | None = None,
|
|
124
|
+
) -> ModelRequest:
|
|
125
|
+
adapted_tools = tools if binding.capabilities.supports_tools else None
|
|
126
|
+
adapted_rf = response_format if binding.capabilities.supports_json else None
|
|
127
|
+
|
|
128
|
+
return ModelRequest(
|
|
129
|
+
model=binding.model,
|
|
130
|
+
messages=messages,
|
|
131
|
+
system=system,
|
|
132
|
+
tools=adapted_tools,
|
|
133
|
+
tool_choice=tool_choice if adapted_tools else None,
|
|
134
|
+
response_format=adapted_rf,
|
|
135
|
+
temperature=temperature,
|
|
136
|
+
max_tokens=max_tokens,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _record(
|
|
140
|
+
self,
|
|
141
|
+
binding: ModelBinding,
|
|
142
|
+
*,
|
|
143
|
+
run_id: str | None = None,
|
|
144
|
+
latency_ms: float | None = None,
|
|
145
|
+
response: ModelResponse | None = None,
|
|
146
|
+
error: str | None = None,
|
|
147
|
+
attempts: int = 1,
|
|
148
|
+
) -> None:
|
|
149
|
+
if self._usage is None:
|
|
150
|
+
return
|
|
151
|
+
rec = ModelUsageRecord(
|
|
152
|
+
run_id=run_id,
|
|
153
|
+
agent=binding.agent,
|
|
154
|
+
provider=binding.provider_name,
|
|
155
|
+
model=binding.model,
|
|
156
|
+
input_tokens=response.usage.input_tokens if response and response.usage else None,
|
|
157
|
+
output_tokens=response.usage.output_tokens if response and response.usage else None,
|
|
158
|
+
total_tokens=response.usage.total_tokens if response and response.usage else None,
|
|
159
|
+
latency_ms=latency_ms,
|
|
160
|
+
attempts=attempts,
|
|
161
|
+
success=error is None,
|
|
162
|
+
error=error,
|
|
163
|
+
)
|
|
164
|
+
try:
|
|
165
|
+
self._usage.insert(rec)
|
|
166
|
+
except Exception:
|
|
167
|
+
logger.warning("Failed to record model usage", exc_info=True)
|
|
168
|
+
|
|
169
|
+
async def generate(
|
|
170
|
+
self,
|
|
171
|
+
agent: str,
|
|
172
|
+
*,
|
|
173
|
+
messages: list[Message],
|
|
174
|
+
system: str | None = None,
|
|
175
|
+
tools: list[dict[str, Any]] | None = None,
|
|
176
|
+
tool_choice: str | None = None,
|
|
177
|
+
response_format: dict[str, str] | None = None,
|
|
178
|
+
temperature: float | None = None,
|
|
179
|
+
max_tokens: int | None = None,
|
|
180
|
+
run_id: str | None = None,
|
|
181
|
+
) -> ModelResponse:
|
|
182
|
+
primary = self._resolve_binding(agent)
|
|
183
|
+
req = self._build_request(
|
|
184
|
+
primary,
|
|
185
|
+
messages,
|
|
186
|
+
system=system,
|
|
187
|
+
tools=tools,
|
|
188
|
+
tool_choice=tool_choice,
|
|
189
|
+
response_format=response_format,
|
|
190
|
+
temperature=temperature,
|
|
191
|
+
max_tokens=max_tokens,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
start = time.perf_counter()
|
|
195
|
+
try:
|
|
196
|
+
resp = await primary.provider.generate(req)
|
|
197
|
+
self._record(primary, run_id=run_id, latency_ms=(time.perf_counter() - start) * 1000, response=resp, attempts=resp.attempts)
|
|
198
|
+
return resp
|
|
199
|
+
except Exception as primary_exc:
|
|
200
|
+
latency = (time.perf_counter() - start) * 1000
|
|
201
|
+
self._record(primary, run_id=run_id, latency_ms=latency, error=f"{type(primary_exc).__name__}: {primary_exc}", attempts=getattr(primary_exc, "attempts", 1))
|
|
202
|
+
|
|
203
|
+
# Fallback chain (§30)
|
|
204
|
+
if self._config.fallback.enabled and self._config.fallback.providers:
|
|
205
|
+
for fb_name in self._config.fallback.providers:
|
|
206
|
+
if fb_name == primary.provider_name:
|
|
207
|
+
continue
|
|
208
|
+
fb_cfg = self._config.providers.get(fb_name)
|
|
209
|
+
if not fb_cfg or not fb_cfg.default_model:
|
|
210
|
+
continue
|
|
211
|
+
fb_binding = ModelBinding(
|
|
212
|
+
agent=agent,
|
|
213
|
+
provider_name=fb_name,
|
|
214
|
+
model=fb_cfg.default_model,
|
|
215
|
+
provider=self._provider(fb_name),
|
|
216
|
+
capabilities=self._registry.lookup(fb_cfg.default_model),
|
|
217
|
+
)
|
|
218
|
+
fb_req = self._build_request(
|
|
219
|
+
fb_binding,
|
|
220
|
+
messages,
|
|
221
|
+
system=system,
|
|
222
|
+
tools=tools,
|
|
223
|
+
tool_choice=tool_choice,
|
|
224
|
+
response_format=response_format,
|
|
225
|
+
temperature=temperature,
|
|
226
|
+
max_tokens=max_tokens,
|
|
227
|
+
)
|
|
228
|
+
fb_start = time.perf_counter()
|
|
229
|
+
try:
|
|
230
|
+
logger.warning("Primary provider %s failed; falling back to %s", primary.provider_name, fb_name)
|
|
231
|
+
fb_resp = await fb_binding.provider.generate(fb_req)
|
|
232
|
+
self._record(fb_binding, run_id=run_id, latency_ms=(time.perf_counter() - fb_start) * 1000, response=fb_resp, attempts=fb_resp.attempts)
|
|
233
|
+
return fb_resp
|
|
234
|
+
except Exception as fb_exc:
|
|
235
|
+
self._record(fb_binding, run_id=run_id, latency_ms=(time.perf_counter() - fb_start) * 1000, error=f"{type(fb_exc).__name__}: {fb_exc}", attempts=getattr(fb_exc, "attempts", 1))
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
raise primary_exc
|
|
239
|
+
|
|
240
|
+
async def generate_with(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
provider: str,
|
|
244
|
+
model: str,
|
|
245
|
+
agent: str,
|
|
246
|
+
messages: list[Message],
|
|
247
|
+
system: str | None = None,
|
|
248
|
+
tools: list[dict[str, Any]] | None = None,
|
|
249
|
+
tool_choice: str | None = None,
|
|
250
|
+
response_format: dict[str, str] | None = None,
|
|
251
|
+
temperature: float | None = None,
|
|
252
|
+
max_tokens: int | None = None,
|
|
253
|
+
run_id: str | None = None,
|
|
254
|
+
) -> ModelResponse:
|
|
255
|
+
"""Generate against an EXPLICIT provider/model binding with NO fallback."""
|
|
256
|
+
binding = self.binding_for(provider, model)
|
|
257
|
+
req = self._build_request(
|
|
258
|
+
binding,
|
|
259
|
+
messages,
|
|
260
|
+
system=system,
|
|
261
|
+
tools=tools,
|
|
262
|
+
tool_choice=tool_choice,
|
|
263
|
+
response_format=response_format,
|
|
264
|
+
temperature=temperature,
|
|
265
|
+
max_tokens=max_tokens,
|
|
266
|
+
)
|
|
267
|
+
start = time.perf_counter()
|
|
268
|
+
try:
|
|
269
|
+
resp = await binding.provider.generate(req)
|
|
270
|
+
self._record(binding, run_id=run_id, latency_ms=(time.perf_counter() - start) * 1000, response=resp, attempts=resp.attempts)
|
|
271
|
+
return resp
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
self._record(binding, run_id=run_id, latency_ms=(time.perf_counter() - start) * 1000, error=f"{type(exc).__name__}: {exc}", attempts=getattr(exc, "attempts", 1))
|
|
274
|
+
raise
|
|
275
|
+
|
|
276
|
+
async def stream(
|
|
277
|
+
self,
|
|
278
|
+
agent: str,
|
|
279
|
+
*,
|
|
280
|
+
messages: list[Message],
|
|
281
|
+
system: str | None = None,
|
|
282
|
+
temperature: float | None = None,
|
|
283
|
+
) -> AsyncIterator[str]:
|
|
284
|
+
binding = self._resolve_binding(agent)
|
|
285
|
+
req = ModelRequest(
|
|
286
|
+
model=binding.model,
|
|
287
|
+
messages=messages,
|
|
288
|
+
system=system,
|
|
289
|
+
temperature=temperature,
|
|
290
|
+
)
|
|
291
|
+
async for chunk in binding.provider.stream(req):
|
|
292
|
+
yield chunk
|
|
293
|
+
|
|
294
|
+
def health_all(self) -> dict[str, ProviderHealth]:
|
|
295
|
+
results: dict[str, ProviderHealth] = {}
|
|
296
|
+
for name in self._config.providers:
|
|
297
|
+
try:
|
|
298
|
+
prov = self._provider(name)
|
|
299
|
+
# Run async health in an event loop or thread
|
|
300
|
+
try:
|
|
301
|
+
loop = asyncio.get_running_loop()
|
|
302
|
+
except RuntimeError:
|
|
303
|
+
loop = None
|
|
304
|
+
if loop and loop.is_running():
|
|
305
|
+
import concurrent.futures
|
|
306
|
+
|
|
307
|
+
def _run_health(p: ModelProvider = prov) -> ProviderHealth:
|
|
308
|
+
return asyncio.run(p.health())
|
|
309
|
+
|
|
310
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
311
|
+
health = pool.submit(_run_health).result()
|
|
312
|
+
else:
|
|
313
|
+
health = asyncio.run(prov.health())
|
|
314
|
+
results[name] = health
|
|
315
|
+
except (ConfigError, ProviderError) as exc:
|
|
316
|
+
results[name] = ProviderHealth(provider=name, ok=False, detail=str(exc))
|
|
317
|
+
except Exception as exc: # noqa: BLE001
|
|
318
|
+
results[name] = ProviderHealth(provider=name, ok=False, detail=str(exc))
|
|
319
|
+
return results
|
|
320
|
+
|
|
321
|
+
async def aclose(self) -> None:
|
|
322
|
+
for p in self._providers.values():
|
|
323
|
+
await p.aclose()
|
|
324
|
+
self._providers.clear()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def collect_health(config: ForgexConfig) -> dict[str, ProviderHealth]:
|
|
328
|
+
router = ModelRouter(config)
|
|
329
|
+
try:
|
|
330
|
+
return router.health_all()
|
|
331
|
+
finally:
|
|
332
|
+
asyncio.run(router.aclose())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Orchestration: state persistence, events, workflow rules, and engine."""
|
|
2
|
+
|
|
3
|
+
from forgex.orchestration.checkpoints import (
|
|
4
|
+
create_checkpoint,
|
|
5
|
+
get_checkpoint,
|
|
6
|
+
latest_checkpoint,
|
|
7
|
+
list_checkpoints,
|
|
8
|
+
rollback_to,
|
|
9
|
+
)
|
|
10
|
+
from forgex.orchestration.events import EventRecorder, read_events
|
|
11
|
+
from forgex.orchestration.lockfile import RunLock
|
|
12
|
+
from forgex.orchestration.retry import RetryPolicy, is_retryable, run_with_retries
|
|
13
|
+
from forgex.orchestration.state import StateStore, TaskStore
|
|
14
|
+
from forgex.orchestration.stores import ProjectStores, open_stores
|
|
15
|
+
from forgex.orchestration.workflow import ALLOWED_TRANSITIONS, can_transition, transition
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ALLOWED_TRANSITIONS",
|
|
19
|
+
"EventRecorder",
|
|
20
|
+
"ProjectStores",
|
|
21
|
+
"RetryPolicy",
|
|
22
|
+
"RunLock",
|
|
23
|
+
"StateStore",
|
|
24
|
+
"TaskStore",
|
|
25
|
+
"can_transition",
|
|
26
|
+
"create_checkpoint",
|
|
27
|
+
"get_checkpoint",
|
|
28
|
+
"is_retryable",
|
|
29
|
+
"latest_checkpoint",
|
|
30
|
+
"list_checkpoints",
|
|
31
|
+
"open_stores",
|
|
32
|
+
"read_events",
|
|
33
|
+
"rollback_to",
|
|
34
|
+
"run_with_retries",
|
|
35
|
+
"transition",
|
|
36
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Git checkpoint system (spec section 21).
|
|
2
|
+
|
|
3
|
+
rollback_to: create a SAFETY checkpoint of the current state FIRST, then
|
|
4
|
+
git reset --hard to the target commit — so every rollback is itself
|
|
5
|
+
reversible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from forgex.orchestration.stores import ProjectStores
|
|
14
|
+
from forgex.schemas.checkpoint import CheckpointRecord
|
|
15
|
+
from forgex.schemas.error import GitError
|
|
16
|
+
from forgex.schemas.event import EventType
|
|
17
|
+
from forgex.tools.git import git_checkpoint, git_reset_hard, has_git_repo
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("forgex.checkpoints")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _project_id(stores: ProjectStores) -> str:
|
|
23
|
+
state = stores.state.load()
|
|
24
|
+
return state.project_id if state else "unknown"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_checkpoint(
|
|
28
|
+
label: str,
|
|
29
|
+
*,
|
|
30
|
+
stores: ProjectStores,
|
|
31
|
+
description: str | None = None,
|
|
32
|
+
run_id: str | None = None,
|
|
33
|
+
) -> CheckpointRecord | None:
|
|
34
|
+
"""Commit current state and record it. Returns None when git is unavailable."""
|
|
35
|
+
if not has_git_repo(stores.root):
|
|
36
|
+
return None
|
|
37
|
+
commit = git_checkpoint(stores.root, label)
|
|
38
|
+
if commit is None:
|
|
39
|
+
logger.warning("checkpoint %r: git commit failed", label)
|
|
40
|
+
return None
|
|
41
|
+
record = CheckpointRecord(
|
|
42
|
+
id=uuid.uuid4().hex[:12],
|
|
43
|
+
project_id=_project_id(stores),
|
|
44
|
+
label=label,
|
|
45
|
+
git_commit=commit,
|
|
46
|
+
description=description,
|
|
47
|
+
run_id=run_id,
|
|
48
|
+
)
|
|
49
|
+
if stores.checkpoints is not None:
|
|
50
|
+
stores.checkpoints.insert(record)
|
|
51
|
+
stores.recorder.record(
|
|
52
|
+
EventType.CHECKPOINT_CREATED,
|
|
53
|
+
{"id": record.id, "label": label, "commit": commit},
|
|
54
|
+
run_id=run_id,
|
|
55
|
+
)
|
|
56
|
+
return record
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_checkpoint(checkpoint_id: str, *, stores: ProjectStores) -> CheckpointRecord | None:
|
|
60
|
+
if stores.checkpoints is None:
|
|
61
|
+
return None
|
|
62
|
+
return stores.checkpoints.get(checkpoint_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def latest_checkpoint(*, stores: ProjectStores) -> CheckpointRecord | None:
|
|
66
|
+
if stores.checkpoints is None:
|
|
67
|
+
return None
|
|
68
|
+
return stores.checkpoints.latest(_project_id(stores))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def list_checkpoints(*, stores: ProjectStores, limit: int = 20) -> list[CheckpointRecord]:
|
|
72
|
+
if stores.checkpoints is None:
|
|
73
|
+
return []
|
|
74
|
+
return stores.checkpoints.list(_project_id(stores), limit)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def find_pre_task_checkpoint(task_id: str, *, stores: ProjectStores) -> CheckpointRecord | None:
|
|
78
|
+
target_label = f"pre-{task_id}"
|
|
79
|
+
for cp in list_checkpoints(stores=stores, limit=100):
|
|
80
|
+
if cp.label == target_label:
|
|
81
|
+
return cp
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def rollback_to(
|
|
86
|
+
record: CheckpointRecord,
|
|
87
|
+
*,
|
|
88
|
+
stores: ProjectStores,
|
|
89
|
+
run_id: str | None = None,
|
|
90
|
+
) -> CheckpointRecord | None:
|
|
91
|
+
if not record.git_commit:
|
|
92
|
+
raise GitError(f"Checkpoint {record.id} has no git commit hash")
|
|
93
|
+
|
|
94
|
+
# 1. Safety checkpoint of current state
|
|
95
|
+
safety = create_checkpoint(
|
|
96
|
+
f"pre-rollback-{record.id}",
|
|
97
|
+
stores=stores,
|
|
98
|
+
description=f"Safety checkpoint before rollback to {record.id}",
|
|
99
|
+
run_id=run_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 2. Reset hard
|
|
103
|
+
ok = git_reset_hard(stores.root, record.git_commit)
|
|
104
|
+
if not ok:
|
|
105
|
+
raise GitError(f"Failed to reset workspace to commit {record.git_commit}")
|
|
106
|
+
|
|
107
|
+
# 3. Record event
|
|
108
|
+
stores.recorder.record(
|
|
109
|
+
EventType.CHECKPOINT_ROLLED_BACK,
|
|
110
|
+
{
|
|
111
|
+
"to": record.id,
|
|
112
|
+
"commit": record.git_commit,
|
|
113
|
+
"safety_checkpoint": safety.id if safety else None,
|
|
114
|
+
},
|
|
115
|
+
run_id=run_id,
|
|
116
|
+
)
|
|
117
|
+
return safety
|