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
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""SQLite repositories (spec section 25).
|
|
2
|
+
|
|
3
|
+
Pure persistence: parameterized SQL, no business logic, no JSON-file
|
|
4
|
+
knowledge. Every sqlite error is wrapped in StorageError so callers only
|
|
5
|
+
ever see controlled FORGEX failures (spec section 28).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from forgex.schemas.checkpoint import CheckpointRecord
|
|
17
|
+
from forgex.schemas.error import ErrorRecord, StorageError
|
|
18
|
+
from forgex.schemas.event import Event, EventType
|
|
19
|
+
from forgex.schemas.project import ProjectState, RunState, RunStatus, WorkflowStatus
|
|
20
|
+
from forgex.schemas.result import ModelUsageRecord
|
|
21
|
+
from forgex.schemas.task import Task, TaskPriority, TaskStatus
|
|
22
|
+
from forgex.storage.database import Database
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _iso(value: datetime) -> str:
|
|
26
|
+
return value.isoformat()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _dt(value: str | None) -> datetime | None:
|
|
30
|
+
return datetime.fromisoformat(value) if value else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProjectRepository:
|
|
34
|
+
def __init__(self, db: Database) -> None:
|
|
35
|
+
self._db = db
|
|
36
|
+
|
|
37
|
+
def upsert(self, state: ProjectState) -> None:
|
|
38
|
+
try:
|
|
39
|
+
self._db.conn.execute(
|
|
40
|
+
"""
|
|
41
|
+
INSERT INTO projects (project_id, path, name, status, created_at, updated_at)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
43
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
44
|
+
path = excluded.path,
|
|
45
|
+
name = excluded.name,
|
|
46
|
+
status = excluded.status,
|
|
47
|
+
updated_at = excluded.updated_at
|
|
48
|
+
""",
|
|
49
|
+
(
|
|
50
|
+
state.project_id,
|
|
51
|
+
state.project_path,
|
|
52
|
+
Path(state.project_path).name,
|
|
53
|
+
state.status.value,
|
|
54
|
+
_iso(state.created_at),
|
|
55
|
+
_iso(state.updated_at),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
self._db.conn.commit()
|
|
59
|
+
except sqlite3.Error as exc:
|
|
60
|
+
raise StorageError(f"Failed to upsert project: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
def get(self, project_id: str) -> ProjectState | None:
|
|
63
|
+
try:
|
|
64
|
+
row = self._db.conn.execute(
|
|
65
|
+
"SELECT * FROM projects WHERE project_id = ?", (project_id,)
|
|
66
|
+
).fetchone()
|
|
67
|
+
if not row:
|
|
68
|
+
return None
|
|
69
|
+
data = dict(row)
|
|
70
|
+
return ProjectState(
|
|
71
|
+
project_id=data["project_id"],
|
|
72
|
+
project_path=data.get("path") or "",
|
|
73
|
+
status=WorkflowStatus(data["status"]),
|
|
74
|
+
created_at=_dt(data.get("created_at")) or datetime.now(UTC),
|
|
75
|
+
updated_at=_dt(data.get("updated_at")) or datetime.now(UTC),
|
|
76
|
+
)
|
|
77
|
+
except sqlite3.Error as exc:
|
|
78
|
+
raise StorageError(f"Failed to get project: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RunRepository:
|
|
82
|
+
def __init__(self, db: Database) -> None:
|
|
83
|
+
self._db = db
|
|
84
|
+
|
|
85
|
+
def create(self, run: RunState) -> None:
|
|
86
|
+
try:
|
|
87
|
+
self._db.conn.execute(
|
|
88
|
+
"""
|
|
89
|
+
INSERT INTO runs (run_id, project_id, request, status, phase, iteration, started_at, finished_at, summary, error)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
91
|
+
""",
|
|
92
|
+
(
|
|
93
|
+
run.run_id,
|
|
94
|
+
run.project_id,
|
|
95
|
+
run.request,
|
|
96
|
+
run.status.value,
|
|
97
|
+
run.phase.value,
|
|
98
|
+
run.iteration,
|
|
99
|
+
_iso(run.started_at),
|
|
100
|
+
_iso(run.finished_at) if run.finished_at else None,
|
|
101
|
+
run.summary,
|
|
102
|
+
run.error,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
self._db.conn.commit()
|
|
106
|
+
except sqlite3.Error as exc:
|
|
107
|
+
raise StorageError(f"Failed to create run: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
def update(self, run: RunState) -> None:
|
|
110
|
+
try:
|
|
111
|
+
self._db.conn.execute(
|
|
112
|
+
"""
|
|
113
|
+
UPDATE runs
|
|
114
|
+
SET status = ?, phase = ?, iteration = ?, finished_at = ?, summary = ?, error = ?
|
|
115
|
+
WHERE run_id = ?
|
|
116
|
+
""",
|
|
117
|
+
(
|
|
118
|
+
run.status.value,
|
|
119
|
+
run.phase.value,
|
|
120
|
+
run.iteration,
|
|
121
|
+
_iso(run.finished_at) if run.finished_at else None,
|
|
122
|
+
run.summary,
|
|
123
|
+
run.error,
|
|
124
|
+
run.run_id,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
self._db.conn.commit()
|
|
128
|
+
except sqlite3.Error as exc:
|
|
129
|
+
raise StorageError(f"Failed to update run: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
def get(self, run_id: str) -> RunState | None:
|
|
132
|
+
try:
|
|
133
|
+
row = self._db.conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
|
|
134
|
+
if not row:
|
|
135
|
+
return None
|
|
136
|
+
return self._to_run(row)
|
|
137
|
+
except sqlite3.Error as exc:
|
|
138
|
+
raise StorageError(f"Failed to get run: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
def list_recent(self, project_id: str | None = None, limit: int = 50) -> list[RunState]:
|
|
141
|
+
try:
|
|
142
|
+
if project_id:
|
|
143
|
+
rows = self._db.conn.execute(
|
|
144
|
+
"SELECT * FROM runs WHERE project_id = ? ORDER BY started_at DESC LIMIT ?",
|
|
145
|
+
(project_id, limit),
|
|
146
|
+
).fetchall()
|
|
147
|
+
else:
|
|
148
|
+
rows = self._db.conn.execute(
|
|
149
|
+
"SELECT * FROM runs ORDER BY started_at DESC LIMIT ?", (limit,)
|
|
150
|
+
).fetchall()
|
|
151
|
+
return [self._to_run(r) for r in rows]
|
|
152
|
+
except sqlite3.Error as exc:
|
|
153
|
+
raise StorageError(f"Failed to list runs: {exc}") from exc
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _to_run(row: sqlite3.Row) -> RunState:
|
|
157
|
+
return RunState(
|
|
158
|
+
run_id=row["run_id"],
|
|
159
|
+
project_id=row["project_id"],
|
|
160
|
+
request=row["request"],
|
|
161
|
+
status=RunStatus(row["status"]),
|
|
162
|
+
phase=WorkflowStatus(row["phase"]),
|
|
163
|
+
iteration=row["iteration"],
|
|
164
|
+
started_at=_dt(row["started_at"]) or datetime.now(UTC),
|
|
165
|
+
finished_at=_dt(row["finished_at"]),
|
|
166
|
+
summary=row["summary"],
|
|
167
|
+
error=row["error"],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TaskRepository:
|
|
172
|
+
def __init__(self, db: Database) -> None:
|
|
173
|
+
self._db = db
|
|
174
|
+
|
|
175
|
+
def upsert(self, task: Task, project_id: str) -> None:
|
|
176
|
+
try:
|
|
177
|
+
self._db.conn.execute(
|
|
178
|
+
"""
|
|
179
|
+
INSERT INTO tasks (id, project_id, description, priority, status, depends_on, files, acceptance_criteria, attempts, created_at, updated_at, result)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
181
|
+
ON CONFLICT(id, project_id) DO UPDATE SET
|
|
182
|
+
description = excluded.description,
|
|
183
|
+
priority = excluded.priority,
|
|
184
|
+
status = excluded.status,
|
|
185
|
+
depends_on = excluded.depends_on,
|
|
186
|
+
files = excluded.files,
|
|
187
|
+
acceptance_criteria = excluded.acceptance_criteria,
|
|
188
|
+
attempts = excluded.attempts,
|
|
189
|
+
updated_at = excluded.updated_at,
|
|
190
|
+
result = excluded.result
|
|
191
|
+
""",
|
|
192
|
+
(
|
|
193
|
+
task.id,
|
|
194
|
+
project_id,
|
|
195
|
+
task.description,
|
|
196
|
+
task.priority.value,
|
|
197
|
+
task.status.value,
|
|
198
|
+
json.dumps(task.depends_on),
|
|
199
|
+
json.dumps(task.files),
|
|
200
|
+
json.dumps(task.acceptance_criteria),
|
|
201
|
+
task.attempts,
|
|
202
|
+
_iso(task.created_at),
|
|
203
|
+
_iso(task.updated_at),
|
|
204
|
+
task.result,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
self._db.conn.commit()
|
|
208
|
+
except sqlite3.Error as exc:
|
|
209
|
+
raise StorageError(f"Failed to upsert task: {exc}") from exc
|
|
210
|
+
|
|
211
|
+
def list(self, project_id: str) -> list[Task]:
|
|
212
|
+
try:
|
|
213
|
+
rows = self._db.conn.execute(
|
|
214
|
+
"SELECT * FROM tasks WHERE project_id = ? ORDER BY rowid ASC", (project_id,)
|
|
215
|
+
).fetchall()
|
|
216
|
+
return [self._to_task(r) for r in rows]
|
|
217
|
+
except sqlite3.Error as exc:
|
|
218
|
+
raise StorageError(f"Failed to list tasks: {exc}") from exc
|
|
219
|
+
|
|
220
|
+
def get(self, task_id: str, project_id: str) -> Task | None:
|
|
221
|
+
try:
|
|
222
|
+
row = self._db.conn.execute(
|
|
223
|
+
"SELECT * FROM tasks WHERE id = ? AND project_id = ?", (task_id, project_id)
|
|
224
|
+
).fetchone()
|
|
225
|
+
return self._to_task(row) if row else None
|
|
226
|
+
except sqlite3.Error as exc:
|
|
227
|
+
raise StorageError(f"Failed to get task: {exc}") from exc
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _to_task(row: sqlite3.Row) -> Task:
|
|
231
|
+
return Task(
|
|
232
|
+
id=row["id"],
|
|
233
|
+
description=row["description"],
|
|
234
|
+
priority=TaskPriority(row["priority"]),
|
|
235
|
+
status=TaskStatus(row["status"]),
|
|
236
|
+
depends_on=json.loads(row["depends_on"]),
|
|
237
|
+
files=json.loads(row["files"]),
|
|
238
|
+
acceptance_criteria=json.loads(row["acceptance_criteria"]),
|
|
239
|
+
attempts=row["attempts"],
|
|
240
|
+
created_at=_dt(row["created_at"]) or datetime.now(UTC),
|
|
241
|
+
updated_at=_dt(row["updated_at"]) or datetime.now(UTC),
|
|
242
|
+
result=row["result"],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class EventRepository:
|
|
247
|
+
def __init__(self, db: Database) -> None:
|
|
248
|
+
self._db = db
|
|
249
|
+
|
|
250
|
+
def insert(self, event: Event) -> None:
|
|
251
|
+
try:
|
|
252
|
+
self._db.conn.execute(
|
|
253
|
+
"INSERT INTO events (ts, type, run_id, data) VALUES (?, ?, ?, ?)",
|
|
254
|
+
(event.ts.isoformat(), event.type.value, event.run_id, json.dumps(event.data, default=str)),
|
|
255
|
+
)
|
|
256
|
+
self._db.conn.commit()
|
|
257
|
+
except sqlite3.Error as exc:
|
|
258
|
+
raise StorageError(f"Failed to insert event: {exc}") from exc
|
|
259
|
+
|
|
260
|
+
def count(self) -> int:
|
|
261
|
+
try:
|
|
262
|
+
row = self._db.conn.execute("SELECT COUNT(*) FROM events").fetchone()
|
|
263
|
+
return row[0] if row else 0
|
|
264
|
+
except sqlite3.Error as exc:
|
|
265
|
+
raise StorageError(f"Failed to count events: {exc}") from exc
|
|
266
|
+
|
|
267
|
+
def recent(self, limit: int = 50) -> list[Event]:
|
|
268
|
+
try:
|
|
269
|
+
rows = self._db.conn.execute(
|
|
270
|
+
"SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)
|
|
271
|
+
).fetchall()
|
|
272
|
+
return [
|
|
273
|
+
Event(
|
|
274
|
+
ts=_dt(r["ts"]) or datetime.now(UTC),
|
|
275
|
+
type=EventType(r["type"]),
|
|
276
|
+
run_id=r["run_id"],
|
|
277
|
+
data=json.loads(r["data"]),
|
|
278
|
+
)
|
|
279
|
+
for r in rows
|
|
280
|
+
]
|
|
281
|
+
except sqlite3.Error as exc:
|
|
282
|
+
raise StorageError(f"Failed to get recent events: {exc}") from exc
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class ErrorRepository:
|
|
286
|
+
def __init__(self, db: Database) -> None:
|
|
287
|
+
self._db = db
|
|
288
|
+
|
|
289
|
+
def insert(self, error: ErrorRecord, project_id: str) -> None:
|
|
290
|
+
try:
|
|
291
|
+
self._db.conn.execute(
|
|
292
|
+
"""
|
|
293
|
+
INSERT INTO errors (project_id, run_id, ts, source, kind, message, detail)
|
|
294
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
295
|
+
""",
|
|
296
|
+
(
|
|
297
|
+
project_id,
|
|
298
|
+
error.run_id,
|
|
299
|
+
_iso(error.ts),
|
|
300
|
+
error.source,
|
|
301
|
+
error.kind,
|
|
302
|
+
error.message,
|
|
303
|
+
json.dumps(error.detail, default=str) if error.detail else None,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
self._db.conn.commit()
|
|
307
|
+
except sqlite3.Error as exc:
|
|
308
|
+
raise StorageError(f"Failed to insert error: {exc}") from exc
|
|
309
|
+
|
|
310
|
+
def list(self, project_id: str, limit: int = 50) -> list[ErrorRecord]:
|
|
311
|
+
try:
|
|
312
|
+
rows = self._db.conn.execute(
|
|
313
|
+
"SELECT * FROM errors WHERE project_id = ? ORDER BY id DESC LIMIT ?",
|
|
314
|
+
(project_id, limit),
|
|
315
|
+
).fetchall()
|
|
316
|
+
return [
|
|
317
|
+
ErrorRecord(
|
|
318
|
+
ts=_dt(r["ts"]) or datetime.now(UTC),
|
|
319
|
+
source=r["source"],
|
|
320
|
+
kind=r["kind"],
|
|
321
|
+
message=r["message"],
|
|
322
|
+
run_id=r["run_id"],
|
|
323
|
+
detail=json.loads(r["detail"]) if r["detail"] else None,
|
|
324
|
+
)
|
|
325
|
+
for r in rows
|
|
326
|
+
]
|
|
327
|
+
except sqlite3.Error as exc:
|
|
328
|
+
raise StorageError(f"Failed to list errors: {exc}") from exc
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ModelUsageRepository:
|
|
332
|
+
"""Persists per-call model usage (spec sections 40, 41)."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, db: Database) -> None:
|
|
335
|
+
self._db = db
|
|
336
|
+
|
|
337
|
+
def insert(self, record: ModelUsageRecord) -> None:
|
|
338
|
+
try:
|
|
339
|
+
self._db.conn.execute(
|
|
340
|
+
"""
|
|
341
|
+
INSERT INTO model_usage (ts, run_id, agent, provider, model,
|
|
342
|
+
input_tokens, output_tokens, total_tokens,
|
|
343
|
+
latency_ms, attempts, success, error)
|
|
344
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
345
|
+
""",
|
|
346
|
+
(
|
|
347
|
+
_iso(record.ts),
|
|
348
|
+
record.run_id,
|
|
349
|
+
record.agent,
|
|
350
|
+
record.provider,
|
|
351
|
+
record.model,
|
|
352
|
+
record.input_tokens,
|
|
353
|
+
record.output_tokens,
|
|
354
|
+
record.total_tokens,
|
|
355
|
+
record.latency_ms,
|
|
356
|
+
record.attempts,
|
|
357
|
+
int(record.success),
|
|
358
|
+
record.error,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
self._db.conn.commit()
|
|
362
|
+
except sqlite3.Error as exc:
|
|
363
|
+
raise StorageError(f"Failed to record model usage: {exc}") from exc
|
|
364
|
+
|
|
365
|
+
def recent(self, run_id: str | None = None, limit: int = 50) -> list[ModelUsageRecord]:
|
|
366
|
+
sql = "SELECT * FROM model_usage"
|
|
367
|
+
params: list[Any] = []
|
|
368
|
+
if run_id is not None:
|
|
369
|
+
sql += " WHERE run_id = ?"
|
|
370
|
+
params.append(run_id)
|
|
371
|
+
sql += " ORDER BY id DESC LIMIT ?"
|
|
372
|
+
params.append(limit)
|
|
373
|
+
try:
|
|
374
|
+
rows = self._db.conn.execute(sql, params).fetchall()
|
|
375
|
+
return [self._to_record(row) for row in rows]
|
|
376
|
+
except sqlite3.Error as exc:
|
|
377
|
+
raise StorageError(f"Failed to read model usage: {exc}") from exc
|
|
378
|
+
|
|
379
|
+
def totals(self) -> dict[str, Any]:
|
|
380
|
+
try:
|
|
381
|
+
row = self._db.conn.execute(
|
|
382
|
+
"""
|
|
383
|
+
SELECT COUNT(*) as calls,
|
|
384
|
+
SUM(input_tokens) as input_tokens,
|
|
385
|
+
SUM(output_tokens) as output_tokens,
|
|
386
|
+
SUM(total_tokens) as total_tokens
|
|
387
|
+
FROM model_usage
|
|
388
|
+
"""
|
|
389
|
+
).fetchone()
|
|
390
|
+
if not row:
|
|
391
|
+
return {"calls": 0, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
|
392
|
+
return {
|
|
393
|
+
"calls": row["calls"] or 0,
|
|
394
|
+
"input_tokens": row["input_tokens"],
|
|
395
|
+
"output_tokens": row["output_tokens"],
|
|
396
|
+
"total_tokens": row["total_tokens"],
|
|
397
|
+
}
|
|
398
|
+
except sqlite3.Error as exc:
|
|
399
|
+
raise StorageError(f"Failed to calculate usage totals: {exc}") from exc
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _to_record(row: sqlite3.Row) -> ModelUsageRecord:
|
|
403
|
+
return ModelUsageRecord(
|
|
404
|
+
ts=_dt(row["ts"]) or datetime.now(UTC),
|
|
405
|
+
run_id=row["run_id"],
|
|
406
|
+
agent=row["agent"],
|
|
407
|
+
provider=row["provider"],
|
|
408
|
+
model=row["model"],
|
|
409
|
+
input_tokens=row["input_tokens"],
|
|
410
|
+
output_tokens=row["output_tokens"],
|
|
411
|
+
total_tokens=row["total_tokens"],
|
|
412
|
+
latency_ms=row["latency_ms"],
|
|
413
|
+
attempts=row["attempts"],
|
|
414
|
+
success=bool(row["success"]),
|
|
415
|
+
error=row["error"],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class CheckpointRepository:
|
|
420
|
+
"""Persists checkpoint metadata (spec section 21)."""
|
|
421
|
+
|
|
422
|
+
def __init__(self, db: Database) -> None:
|
|
423
|
+
self._db = db
|
|
424
|
+
|
|
425
|
+
def insert(self, record: CheckpointRecord) -> None:
|
|
426
|
+
try:
|
|
427
|
+
self._db.conn.execute(
|
|
428
|
+
"""
|
|
429
|
+
INSERT INTO checkpoints (id, project_id, label, git_commit,
|
|
430
|
+
description, created_at, run_id)
|
|
431
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
432
|
+
""",
|
|
433
|
+
(
|
|
434
|
+
record.id,
|
|
435
|
+
record.project_id,
|
|
436
|
+
record.label,
|
|
437
|
+
record.git_commit,
|
|
438
|
+
record.description,
|
|
439
|
+
_iso(record.created_at),
|
|
440
|
+
record.run_id,
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
self._db.conn.commit()
|
|
444
|
+
except sqlite3.Error as exc:
|
|
445
|
+
raise StorageError(f"Failed to persist checkpoint: {exc}") from exc
|
|
446
|
+
|
|
447
|
+
def get(self, checkpoint_id: str) -> CheckpointRecord | None:
|
|
448
|
+
try:
|
|
449
|
+
row = self._db.conn.execute(
|
|
450
|
+
"SELECT * FROM checkpoints WHERE id = ?", (checkpoint_id,)
|
|
451
|
+
).fetchone()
|
|
452
|
+
return self._to_record(row) if row else None
|
|
453
|
+
except sqlite3.Error as exc:
|
|
454
|
+
raise StorageError(f"Failed to read checkpoint: {exc}") from exc
|
|
455
|
+
|
|
456
|
+
def latest(self, project_id: str) -> CheckpointRecord | None:
|
|
457
|
+
try:
|
|
458
|
+
row = self._db.conn.execute(
|
|
459
|
+
"""
|
|
460
|
+
SELECT * FROM checkpoints WHERE project_id = ?
|
|
461
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1
|
|
462
|
+
""",
|
|
463
|
+
(project_id,),
|
|
464
|
+
).fetchone()
|
|
465
|
+
return self._to_record(row) if row else None
|
|
466
|
+
except sqlite3.Error as exc:
|
|
467
|
+
raise StorageError(f"Failed to read latest checkpoint: {exc}") from exc
|
|
468
|
+
|
|
469
|
+
def list(self, project_id: str, limit: int = 20) -> list[CheckpointRecord]:
|
|
470
|
+
try:
|
|
471
|
+
rows = self._db.conn.execute(
|
|
472
|
+
"""
|
|
473
|
+
SELECT * FROM checkpoints WHERE project_id = ?
|
|
474
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
475
|
+
""",
|
|
476
|
+
(project_id, limit),
|
|
477
|
+
).fetchall()
|
|
478
|
+
return [self._to_record(r) for r in rows]
|
|
479
|
+
except sqlite3.Error as exc:
|
|
480
|
+
raise StorageError(f"Failed to list checkpoints: {exc}") from exc
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _to_record(row: sqlite3.Row) -> CheckpointRecord:
|
|
484
|
+
return CheckpointRecord(
|
|
485
|
+
id=row["id"],
|
|
486
|
+
project_id=row["project_id"],
|
|
487
|
+
label=row["label"],
|
|
488
|
+
git_commit=row["git_commit"],
|
|
489
|
+
description=row["description"],
|
|
490
|
+
created_at=_dt(row["created_at"]) or datetime.now(UTC),
|
|
491
|
+
run_id=row["run_id"],
|
|
492
|
+
)
|
forgex/tools/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tool system (spec sections 10, 18, 19, 32)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from forgex.config.schema import ForgexConfig
|
|
10
|
+
from forgex.orchestration.events import EventRecorder
|
|
11
|
+
from forgex.sandbox.executor import build_executor
|
|
12
|
+
from forgex.sandbox.permissions import Permission
|
|
13
|
+
from forgex.tools import dependencies as _dependencies
|
|
14
|
+
from forgex.tools import filesystem as _filesystem
|
|
15
|
+
from forgex.tools import git as _git
|
|
16
|
+
from forgex.tools import project as _project
|
|
17
|
+
from forgex.tools import search as _search
|
|
18
|
+
from forgex.tools import terminal as _terminal
|
|
19
|
+
from forgex.tools import testing as _testing
|
|
20
|
+
from forgex.tools.base import (
|
|
21
|
+
SimpleTool,
|
|
22
|
+
Tool,
|
|
23
|
+
ToolCall,
|
|
24
|
+
ToolContext,
|
|
25
|
+
ToolExecutor,
|
|
26
|
+
ToolRegistry,
|
|
27
|
+
ToolResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Permission",
|
|
32
|
+
"SimpleTool",
|
|
33
|
+
"Tool",
|
|
34
|
+
"ToolCall",
|
|
35
|
+
"ToolContext",
|
|
36
|
+
"ToolExecutor",
|
|
37
|
+
"ToolRegistry",
|
|
38
|
+
"ToolResult",
|
|
39
|
+
"build_tool_registry",
|
|
40
|
+
"create_context",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_tool_registry() -> ToolRegistry:
|
|
45
|
+
registry = ToolRegistry()
|
|
46
|
+
for module in (_filesystem, _search, _terminal, _git, _testing, _dependencies, _project):
|
|
47
|
+
for tool in module.create_tools():
|
|
48
|
+
registry.register(tool)
|
|
49
|
+
return registry
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_context(
|
|
53
|
+
workspace: Path | None = None,
|
|
54
|
+
config: ForgexConfig | None = None,
|
|
55
|
+
*,
|
|
56
|
+
events: EventRecorder | None = None,
|
|
57
|
+
approval: Callable[[str], bool] | None = None,
|
|
58
|
+
run_id: str | None = None,
|
|
59
|
+
agent=None,
|
|
60
|
+
**kwargs: Any,
|
|
61
|
+
) -> ToolContext:
|
|
62
|
+
ws = Path(workspace or kwargs.get("workspace") or Path.cwd()).resolve()
|
|
63
|
+
cfg = config or kwargs.get("config") or ForgexConfig()
|
|
64
|
+
executor = build_executor(workspace=ws, config=cfg, approval=approval)
|
|
65
|
+
return ToolContext(
|
|
66
|
+
workspace=ws,
|
|
67
|
+
config=cfg,
|
|
68
|
+
executor=executor,
|
|
69
|
+
events=events,
|
|
70
|
+
approval=approval,
|
|
71
|
+
run_id=run_id,
|
|
72
|
+
agent=agent,
|
|
73
|
+
)
|