devcouncil 0.1.1 → 0.2.0

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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,557 @@
1
+ """Native control-plane storage repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ import uuid
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel
12
+ from sqlalchemy.exc import IntegrityError
13
+ from sqlmodel import Session, col, select
14
+
15
+ from devcouncil.storage.models import (
16
+ AgentHandoffModel,
17
+ CorrectionManifestModel,
18
+ FileChangeEventModel,
19
+ SemanticDiffModel,
20
+ ShellCommandEventModel,
21
+ ShellSessionModel,
22
+ TaskLeaseModel,
23
+ VerificationRunModel,
24
+ )
25
+
26
+
27
+ def _utc_now() -> str:
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ class TaskLeaseRecord(BaseModel):
32
+ id: str
33
+ task_id: str
34
+ owner: str
35
+ agent: Optional[str] = None
36
+ client_id: Optional[str] = None
37
+ run_id: Optional[str] = None
38
+ branch: Optional[str] = None
39
+ lease_token: str
40
+ status: str
41
+ created_at: str
42
+ expires_at: Optional[str] = None
43
+ released_at: Optional[str] = None
44
+
45
+
46
+ class ShellSessionRecord(BaseModel):
47
+ id: str
48
+ task_id: str
49
+ lease_id: Optional[str] = None
50
+ shell: str
51
+ cwd: str
52
+ status: str
53
+ started_at: str
54
+ ended_at: Optional[str] = None
55
+
56
+
57
+ class ShellCommandRecord(BaseModel):
58
+ id: int
59
+ task_id: str
60
+ session_id: Optional[str] = None
61
+ command: str
62
+ status: str
63
+ exit_code: Optional[int] = None
64
+ reason: str = ""
65
+ stdout_path: str = ""
66
+ stderr_path: str = ""
67
+ created_at: str
68
+
69
+
70
+ class FileChangeRecord(BaseModel):
71
+ id: int
72
+ task_id: Optional[str] = None
73
+ lease_id: Optional[str] = None
74
+ session_id: Optional[str] = None
75
+ path: str
76
+ operation: str
77
+ allowed: bool
78
+ reason: str = ""
79
+ created_at: str
80
+
81
+
82
+ class SemanticDiffRecord(BaseModel):
83
+ id: int
84
+ task_id: str
85
+ before_snapshot_path: str
86
+ after_snapshot_path: str
87
+ classifications: list[dict]
88
+ summary: str
89
+ created_at: str
90
+
91
+
92
+ class AgentHandoffRecord(BaseModel):
93
+ id: str
94
+ task_id: str
95
+ from_agent: str
96
+ to_agent: str
97
+ run_id: str
98
+ manifest_path: str
99
+ status: str
100
+ created_at: str
101
+
102
+
103
+ class CorrectionManifestRecord(BaseModel):
104
+ id: str
105
+ task_id: str
106
+ run_id: Optional[str] = None
107
+ manifest_path: str
108
+ retry_budget: int
109
+ attempt: int
110
+ status: str
111
+ created_at: str
112
+
113
+
114
+ class VerificationRunRecord(BaseModel):
115
+ id: str
116
+ task_id: str
117
+ sandbox: str
118
+ environment: dict
119
+ commands: list[dict]
120
+ status: str
121
+ started_at: str
122
+ finished_at: Optional[str] = None
123
+
124
+
125
+ def _lease_from_model(model: TaskLeaseModel) -> TaskLeaseRecord:
126
+ return TaskLeaseRecord.model_validate(model.model_dump())
127
+
128
+
129
+ class TaskLeaseRepository:
130
+ def __init__(self, session: Session):
131
+ self.session = session
132
+
133
+ def acquire(
134
+ self,
135
+ task_id: str,
136
+ owner: str,
137
+ *,
138
+ agent: Optional[str] = None,
139
+ client_id: Optional[str] = None,
140
+ run_id: Optional[str] = None,
141
+ branch: Optional[str] = None,
142
+ ttl_seconds: Optional[int] = None,
143
+ force: bool = False,
144
+ ) -> TaskLeaseRecord:
145
+ active = self.active_for_task(task_id)
146
+ if active is not None and not force:
147
+ raise ValueError(f"Active lease already exists for task {task_id}")
148
+ if active is not None and force:
149
+ existing = self.session.get(TaskLeaseModel, active.id)
150
+ if existing is not None:
151
+ existing.status = "stale"
152
+ existing.released_at = _utc_now()
153
+ self.session.add(existing)
154
+ self.session.commit()
155
+
156
+ lease_id = str(uuid.uuid4())
157
+ token = secrets.token_urlsafe(32)
158
+ expires_at = None
159
+ if ttl_seconds is not None:
160
+ expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
161
+
162
+ model = TaskLeaseModel(
163
+ id=lease_id,
164
+ task_id=task_id,
165
+ owner=owner,
166
+ agent=agent,
167
+ client_id=client_id,
168
+ run_id=run_id,
169
+ branch=branch,
170
+ lease_token=token,
171
+ status="active",
172
+ created_at=_utc_now(),
173
+ expires_at=expires_at,
174
+ )
175
+ self.session.add(model)
176
+ try:
177
+ self.session.commit()
178
+ except IntegrityError as exc:
179
+ # Lost a concurrent race: another writer inserted the active lease between
180
+ # our active_for_task() check and this commit. The partial unique index
181
+ # rejected the duplicate — surface it as the same conflict callers expect.
182
+ self.session.rollback()
183
+ raise ValueError(f"Active lease already exists for task {task_id}") from exc
184
+ self.session.refresh(model)
185
+ return _lease_from_model(model)
186
+
187
+ def release(self, task_id: str, lease_token: str, status: str = "released") -> bool:
188
+ active = self.active_for_task(task_id)
189
+ if active is None or active.lease_token != lease_token:
190
+ return False
191
+ model = self.session.get(TaskLeaseModel, active.id)
192
+ if model is None:
193
+ return False
194
+ model.status = status
195
+ model.released_at = _utc_now()
196
+ self.session.add(model)
197
+ self.session.commit()
198
+ return True
199
+
200
+ def active_for_task(self, task_id: str) -> TaskLeaseRecord | None:
201
+ statement = (
202
+ select(TaskLeaseModel)
203
+ .where(TaskLeaseModel.task_id == task_id)
204
+ .where(TaskLeaseModel.status == "active")
205
+ )
206
+ model = self.session.exec(statement).first()
207
+ if model is None:
208
+ return None
209
+ if model.expires_at:
210
+ expires = datetime.fromisoformat(model.expires_at)
211
+ if expires.tzinfo is None:
212
+ expires = expires.replace(tzinfo=timezone.utc)
213
+ if datetime.now(timezone.utc) > expires:
214
+ model.status = "stale"
215
+ model.released_at = _utc_now()
216
+ self.session.add(model)
217
+ self.session.commit()
218
+ return None
219
+ return _lease_from_model(model)
220
+
221
+ def validate(self, task_id: str, lease_token: str) -> bool:
222
+ active = self.active_for_task(task_id)
223
+ return active is not None and active.lease_token == lease_token
224
+
225
+ def renew(self, task_id: str, lease_token: str, ttl_seconds: int) -> TaskLeaseRecord | None:
226
+ """Push the lease's expiry out by ``ttl_seconds`` from now. Returns the updated
227
+ record, or None when the token is invalid / the lease already expired."""
228
+ active = self.active_for_task(task_id)
229
+ if active is None or active.lease_token != lease_token:
230
+ return None
231
+ model = self.session.get(TaskLeaseModel, active.id)
232
+ if model is None:
233
+ return None
234
+ model.expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
235
+ self.session.add(model)
236
+ self.session.commit()
237
+ self.session.refresh(model)
238
+ return _lease_from_model(model)
239
+
240
+ def list_leases(self, *, active_only: bool = True) -> list[tuple[TaskLeaseRecord, bool]]:
241
+ """Return ``(lease, expired)`` pairs for fleet supervision. ``expired`` means the
242
+ lease is still marked active but past its TTL (a crashed/disconnected agent)."""
243
+ statement = select(TaskLeaseModel)
244
+ if active_only:
245
+ statement = statement.where(TaskLeaseModel.status == "active")
246
+ statement = statement.order_by(col(TaskLeaseModel.created_at).desc())
247
+ now = datetime.now(timezone.utc)
248
+ results: list[tuple[TaskLeaseRecord, bool]] = []
249
+ for model in self.session.exec(statement).all():
250
+ expired = False
251
+ if model.status == "active" and model.expires_at:
252
+ expires = datetime.fromisoformat(model.expires_at)
253
+ if expires.tzinfo is None:
254
+ expires = expires.replace(tzinfo=timezone.utc)
255
+ expired = now > expires
256
+ results.append((_lease_from_model(model), expired))
257
+ return results
258
+
259
+
260
+ class ShellSessionRepository:
261
+ def __init__(self, session: Session):
262
+ self.session = session
263
+
264
+ def start(
265
+ self,
266
+ task_id: str,
267
+ shell: str,
268
+ cwd: str,
269
+ *,
270
+ lease_id: Optional[str] = None,
271
+ status: str = "active",
272
+ ) -> ShellSessionRecord:
273
+ session_id = str(uuid.uuid4())
274
+ model = ShellSessionModel(
275
+ id=session_id,
276
+ task_id=task_id,
277
+ lease_id=lease_id,
278
+ shell=shell,
279
+ cwd=cwd,
280
+ status=status,
281
+ started_at=_utc_now(),
282
+ )
283
+ self.session.add(model)
284
+ self.session.commit()
285
+ self.session.refresh(model)
286
+ return ShellSessionRecord.model_validate(model.model_dump())
287
+
288
+ def finish(self, session_id: str, status: str) -> bool:
289
+ model = self.session.get(ShellSessionModel, session_id)
290
+ if model is None:
291
+ return False
292
+ model.status = status
293
+ model.ended_at = _utc_now()
294
+ self.session.add(model)
295
+ self.session.commit()
296
+ return True
297
+
298
+
299
+ class ShellCommandRepository:
300
+ def __init__(self, session: Session):
301
+ self.session = session
302
+
303
+ def record(
304
+ self,
305
+ task_id: str,
306
+ command: str,
307
+ status: str,
308
+ *,
309
+ session_id: Optional[str] = None,
310
+ exit_code: Optional[int] = None,
311
+ reason: str = "",
312
+ stdout_path: str = "",
313
+ stderr_path: str = "",
314
+ ) -> ShellCommandRecord:
315
+ model = ShellCommandEventModel(
316
+ task_id=task_id,
317
+ session_id=session_id,
318
+ command=command,
319
+ status=status,
320
+ exit_code=exit_code,
321
+ reason=reason,
322
+ stdout_path=stdout_path,
323
+ stderr_path=stderr_path,
324
+ created_at=_utc_now(),
325
+ )
326
+ self.session.add(model)
327
+ self.session.commit()
328
+ self.session.refresh(model)
329
+ return ShellCommandRecord.model_validate(model.model_dump())
330
+
331
+
332
+ class FileChangeRepository:
333
+ def __init__(self, session: Session):
334
+ self.session = session
335
+
336
+ def record(
337
+ self,
338
+ path: str,
339
+ operation: str,
340
+ allowed: bool,
341
+ *,
342
+ task_id: Optional[str] = None,
343
+ lease_id: Optional[str] = None,
344
+ session_id: Optional[str] = None,
345
+ reason: str = "",
346
+ ) -> FileChangeRecord:
347
+ model = FileChangeEventModel(
348
+ task_id=task_id,
349
+ lease_id=lease_id,
350
+ session_id=session_id,
351
+ path=path,
352
+ operation=operation,
353
+ allowed=allowed,
354
+ reason=reason,
355
+ created_at=_utc_now(),
356
+ )
357
+ self.session.add(model)
358
+ self.session.commit()
359
+ self.session.refresh(model)
360
+ return FileChangeRecord.model_validate(model.model_dump())
361
+
362
+ def list_for_task(self, task_id: str) -> list[FileChangeRecord]:
363
+ statement = (
364
+ select(FileChangeEventModel)
365
+ .where(FileChangeEventModel.task_id == task_id)
366
+ .order_by(col(FileChangeEventModel.created_at))
367
+ )
368
+ return [FileChangeRecord.model_validate(m.model_dump()) for m in self.session.exec(statement).all()]
369
+
370
+
371
+ class SemanticDiffRepository:
372
+ def __init__(self, session: Session):
373
+ self.session = session
374
+
375
+ def save(
376
+ self,
377
+ task_id: str,
378
+ before_snapshot_path: str,
379
+ after_snapshot_path: str,
380
+ classifications: list[dict],
381
+ summary: str,
382
+ ) -> SemanticDiffRecord:
383
+ model = SemanticDiffModel(
384
+ task_id=task_id,
385
+ before_snapshot_path=before_snapshot_path,
386
+ after_snapshot_path=after_snapshot_path,
387
+ classifications_json=json.dumps(classifications),
388
+ summary=summary,
389
+ created_at=_utc_now(),
390
+ )
391
+ self.session.add(model)
392
+ self.session.commit()
393
+ self.session.refresh(model)
394
+ return SemanticDiffRecord(
395
+ id=model.id or 0,
396
+ task_id=model.task_id,
397
+ before_snapshot_path=model.before_snapshot_path,
398
+ after_snapshot_path=model.after_snapshot_path,
399
+ classifications=classifications,
400
+ summary=model.summary,
401
+ created_at=model.created_at,
402
+ )
403
+
404
+ def latest_for_task(self, task_id: str) -> SemanticDiffRecord | None:
405
+ statement = (
406
+ select(SemanticDiffModel)
407
+ .where(SemanticDiffModel.task_id == task_id)
408
+ .order_by(col(SemanticDiffModel.created_at).desc())
409
+ )
410
+ model = self.session.exec(statement).first()
411
+ if model is None:
412
+ return None
413
+ return SemanticDiffRecord(
414
+ id=model.id or 0,
415
+ task_id=model.task_id,
416
+ before_snapshot_path=model.before_snapshot_path,
417
+ after_snapshot_path=model.after_snapshot_path,
418
+ classifications=json.loads(model.classifications_json),
419
+ summary=model.summary,
420
+ created_at=model.created_at,
421
+ )
422
+
423
+
424
+ class AgentHandoffRepository:
425
+ def __init__(self, session: Session):
426
+ self.session = session
427
+
428
+ def save(
429
+ self,
430
+ task_id: str,
431
+ from_agent: str,
432
+ to_agent: str,
433
+ run_id: str,
434
+ manifest_path: str,
435
+ status: str,
436
+ *,
437
+ handoff_id: Optional[str] = None,
438
+ ) -> AgentHandoffRecord:
439
+ hid = handoff_id or str(uuid.uuid4())
440
+ model = AgentHandoffModel(
441
+ id=hid,
442
+ task_id=task_id,
443
+ from_agent=from_agent,
444
+ to_agent=to_agent,
445
+ run_id=run_id,
446
+ manifest_path=manifest_path,
447
+ status=status,
448
+ created_at=_utc_now(),
449
+ )
450
+ self.session.add(model)
451
+ self.session.commit()
452
+ self.session.refresh(model)
453
+ return AgentHandoffRecord.model_validate(model.model_dump())
454
+
455
+
456
+ class CorrectionManifestRepository:
457
+ def __init__(self, session: Session):
458
+ self.session = session
459
+
460
+ def save(
461
+ self,
462
+ task_id: str,
463
+ manifest_path: str,
464
+ status: str,
465
+ *,
466
+ run_id: Optional[str] = None,
467
+ retry_budget: int = 3,
468
+ attempt: int = 0,
469
+ manifest_id: Optional[str] = None,
470
+ ) -> CorrectionManifestRecord:
471
+ mid = manifest_id or str(uuid.uuid4())
472
+ model = CorrectionManifestModel(
473
+ id=mid,
474
+ task_id=task_id,
475
+ run_id=run_id,
476
+ manifest_path=manifest_path,
477
+ retry_budget=retry_budget,
478
+ attempt=attempt,
479
+ status=status,
480
+ created_at=_utc_now(),
481
+ )
482
+ self.session.add(model)
483
+ self.session.commit()
484
+ self.session.refresh(model)
485
+ return CorrectionManifestRecord.model_validate(model.model_dump())
486
+
487
+ def latest_for_task(self, task_id: str) -> CorrectionManifestRecord | None:
488
+ statement = (
489
+ select(CorrectionManifestModel)
490
+ .where(CorrectionManifestModel.task_id == task_id)
491
+ .order_by(col(CorrectionManifestModel.created_at).desc())
492
+ )
493
+ model = self.session.exec(statement).first()
494
+ if model is None:
495
+ return None
496
+ return CorrectionManifestRecord.model_validate(model.model_dump())
497
+
498
+
499
+ class VerificationRunRepository:
500
+ def __init__(self, session: Session):
501
+ self.session = session
502
+
503
+ def save(
504
+ self,
505
+ task_id: str,
506
+ sandbox: str,
507
+ environment: dict,
508
+ commands: list[dict],
509
+ status: str,
510
+ *,
511
+ run_id: Optional[str] = None,
512
+ finished_at: Optional[str] = None,
513
+ ) -> VerificationRunRecord:
514
+ rid = run_id or str(uuid.uuid4())
515
+ model = VerificationRunModel(
516
+ id=rid,
517
+ task_id=task_id,
518
+ sandbox=sandbox,
519
+ environment_json=json.dumps(environment),
520
+ commands_json=json.dumps(commands),
521
+ status=status,
522
+ started_at=_utc_now(),
523
+ finished_at=finished_at,
524
+ )
525
+ self.session.add(model)
526
+ self.session.commit()
527
+ self.session.refresh(model)
528
+ return VerificationRunRecord(
529
+ id=model.id,
530
+ task_id=model.task_id,
531
+ sandbox=model.sandbox,
532
+ environment=environment,
533
+ commands=commands,
534
+ status=model.status,
535
+ started_at=model.started_at,
536
+ finished_at=model.finished_at,
537
+ )
538
+
539
+ def list_for_task(self, task_id: str) -> list[VerificationRunRecord]:
540
+ statement = (
541
+ select(VerificationRunModel)
542
+ .where(VerificationRunModel.task_id == task_id)
543
+ .order_by(col(VerificationRunModel.started_at))
544
+ )
545
+ records: list[VerificationRunRecord] = []
546
+ for model in self.session.exec(statement).all():
547
+ records.append(VerificationRunRecord(
548
+ id=model.id,
549
+ task_id=model.task_id,
550
+ sandbox=model.sandbox,
551
+ environment=json.loads(model.environment_json),
552
+ commands=json.loads(model.commands_json),
553
+ status=model.status,
554
+ started_at=model.started_at,
555
+ finished_at=model.finished_at,
556
+ ))
557
+ return records