orch-code 0.1.1

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 (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. package/scripts/release.js +52 -0
@@ -0,0 +1,1498 @@
1
+ package storage
2
+
3
+ import (
4
+ "database/sql"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync/atomic"
11
+ "time"
12
+
13
+ "github.com/furkanbeydemir/orch/internal/config"
14
+ "github.com/furkanbeydemir/orch/internal/models"
15
+ _ "modernc.org/sqlite"
16
+ )
17
+
18
+ const (
19
+ driverName = "sqlite"
20
+ activeSessionKeyNS = "active_session_id:"
21
+ )
22
+
23
+ var (
24
+ ErrSessionNotFound = errors.New("session not found")
25
+ ErrSessionClosed = errors.New("session is closed")
26
+ ErrNameConflict = errors.New("session name conflict")
27
+ idCounter uint64
28
+ )
29
+
30
+ type Store struct {
31
+ repoRoot string
32
+ db *sql.DB
33
+ }
34
+
35
+ type Session struct {
36
+ ID string
37
+ ProjectID string
38
+ Name string
39
+ Status string
40
+ Worktree string
41
+ CreatedAt time.Time
42
+ ClosedAt *time.Time
43
+ IsActive bool
44
+ SessionRef string
45
+ }
46
+
47
+ type RunRecord struct {
48
+ ID string
49
+ SessionID string
50
+ Status string
51
+ Task string
52
+ StartedAt time.Time
53
+ CompletedAt *time.Time
54
+ Error string
55
+ }
56
+
57
+ type SessionMessage struct {
58
+ ID string
59
+ SessionID string
60
+ Role string
61
+ ParentID string
62
+ ProviderID string
63
+ ModelID string
64
+ FinishReason string
65
+ Error string
66
+ CreatedAt time.Time
67
+ }
68
+
69
+ type SessionPart struct {
70
+ ID string
71
+ MessageID string
72
+ Type string
73
+ Payload string
74
+ Compacted bool
75
+ CreatedAt time.Time
76
+ UpdatedAt time.Time
77
+ }
78
+
79
+ type SessionSummary struct {
80
+ SessionID string
81
+ SummaryText string
82
+ UpdatedAt time.Time
83
+ }
84
+
85
+ type SessionMetrics struct {
86
+ SessionID string
87
+ InputTokens int
88
+ OutputTokens int
89
+ TotalCost float64
90
+ TurnCount int
91
+ LastMessageID string
92
+ UpdatedAt time.Time
93
+ }
94
+
95
+ func Open(repoRoot string) (*Store, error) {
96
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
97
+ return nil, err
98
+ }
99
+
100
+ dbPath := filepath.Join(repoRoot, config.OrchDir, "orch.db")
101
+ db, err := sql.Open(driverName, dbPath)
102
+ if err != nil {
103
+ return nil, fmt.Errorf("open sqlite database: %w", err)
104
+ }
105
+
106
+ stmts := []string{
107
+ "PRAGMA foreign_keys = ON;",
108
+ "PRAGMA journal_mode = WAL;",
109
+ "PRAGMA synchronous = NORMAL;",
110
+ "PRAGMA busy_timeout = 5000;",
111
+ }
112
+ for _, stmt := range stmts {
113
+ if _, err := db.Exec(stmt); err != nil {
114
+ _ = db.Close()
115
+ return nil, fmt.Errorf("configure sqlite pragma: %w", err)
116
+ }
117
+ }
118
+
119
+ store := &Store{repoRoot: repoRoot, db: db}
120
+ if err := store.migrate(); err != nil {
121
+ _ = db.Close()
122
+ return nil, err
123
+ }
124
+
125
+ return store, nil
126
+ }
127
+
128
+ func (s *Store) Close() error {
129
+ if s == nil || s.db == nil {
130
+ return nil
131
+ }
132
+ return s.db.Close()
133
+ }
134
+
135
+ func (s *Store) GetOrCreateProject() (string, error) {
136
+ const selectSQL = `SELECT id FROM projects WHERE repo_root = ?`
137
+ var projectID string
138
+ if err := s.db.QueryRow(selectSQL, s.repoRoot).Scan(&projectID); err == nil {
139
+ return projectID, nil
140
+ } else if !errors.Is(err, sql.ErrNoRows) {
141
+ return "", fmt.Errorf("query project: %w", err)
142
+ }
143
+
144
+ now := time.Now().UTC().Format(time.RFC3339Nano)
145
+ projectID = newID("proj")
146
+ const insertSQL = `
147
+ INSERT INTO projects(id, name, repo_root, created_at, updated_at)
148
+ VALUES(?, ?, ?, ?, ?)`
149
+ if _, err := s.db.Exec(insertSQL, projectID, filepath.Base(s.repoRoot), s.repoRoot, now, now); err != nil {
150
+ return "", fmt.Errorf("insert project: %w", err)
151
+ }
152
+
153
+ return projectID, nil
154
+ }
155
+
156
+ func (s *Store) EnsureDefaultSession(projectID string) (Session, error) {
157
+ active, err := s.GetActiveSession(projectID)
158
+ if err == nil {
159
+ return active, nil
160
+ }
161
+ if err != nil && !errors.Is(err, ErrSessionNotFound) {
162
+ return Session{}, err
163
+ }
164
+
165
+ created, err := s.CreateSession(projectID, "default")
166
+ if err != nil {
167
+ if errors.Is(err, ErrNameConflict) {
168
+ selected, selectErr := s.SelectSession(projectID, "default")
169
+ if selectErr != nil {
170
+ return Session{}, selectErr
171
+ }
172
+ return selected, nil
173
+ }
174
+ return Session{}, err
175
+ }
176
+
177
+ if err := s.SetActiveSession(projectID, created.ID); err != nil {
178
+ return Session{}, err
179
+ }
180
+ created.IsActive = true
181
+ return created, nil
182
+ }
183
+
184
+ func (s *Store) CreateSession(projectID, name string) (Session, error) {
185
+ return s.CreateSessionWithWorktree(projectID, name, "")
186
+ }
187
+
188
+ func (s *Store) CreateSessionWithWorktree(projectID, name, worktreePath string) (Session, error) {
189
+ name = strings.TrimSpace(name)
190
+ if name == "" {
191
+ return Session{}, fmt.Errorf("session name is required")
192
+ }
193
+
194
+ const dupSQL = `SELECT id FROM sessions WHERE project_id = ? AND name = ?`
195
+ var existingID string
196
+ if err := s.db.QueryRow(dupSQL, projectID, name).Scan(&existingID); err == nil {
197
+ return Session{}, ErrNameConflict
198
+ } else if err != nil && !errors.Is(err, sql.ErrNoRows) {
199
+ return Session{}, fmt.Errorf("check session name conflict: %w", err)
200
+ }
201
+
202
+ now := time.Now().UTC()
203
+ session := Session{
204
+ ID: newID("sess"),
205
+ ProjectID: projectID,
206
+ Name: name,
207
+ Status: "active",
208
+ Worktree: strings.TrimSpace(worktreePath),
209
+ CreatedAt: now,
210
+ }
211
+
212
+ const insertSQL = `
213
+ INSERT INTO sessions(id, project_id, name, status, worktree_path, created_at, closed_at)
214
+ VALUES(?, ?, ?, ?, ?, ?, NULL)`
215
+ if _, err := s.db.Exec(insertSQL, session.ID, session.ProjectID, session.Name, session.Status, session.Worktree, now.Format(time.RFC3339Nano)); err != nil {
216
+ return Session{}, fmt.Errorf("insert session: %w", err)
217
+ }
218
+
219
+ return session, nil
220
+ }
221
+
222
+ func (s *Store) ListSessions(projectID string) ([]Session, error) {
223
+ activeID, _ := s.getMeta(activeSessionKey(projectID))
224
+
225
+ const querySQL = `
226
+ SELECT id, project_id, name, status, worktree_path, created_at, closed_at
227
+ FROM sessions WHERE project_id = ? ORDER BY created_at DESC`
228
+ rows, err := s.db.Query(querySQL, projectID)
229
+ if err != nil {
230
+ return nil, fmt.Errorf("list sessions: %w", err)
231
+ }
232
+ defer rows.Close()
233
+
234
+ result := make([]Session, 0)
235
+ for rows.Next() {
236
+ var sRow Session
237
+ var createdAt string
238
+ var closedAt sql.NullString
239
+ if err := rows.Scan(&sRow.ID, &sRow.ProjectID, &sRow.Name, &sRow.Status, &sRow.Worktree, &createdAt, &closedAt); err != nil {
240
+ return nil, fmt.Errorf("scan session row: %w", err)
241
+ }
242
+ parsedCreated, _ := time.Parse(time.RFC3339Nano, createdAt)
243
+ sRow.CreatedAt = parsedCreated
244
+ if closedAt.Valid {
245
+ parsedClosed, parseErr := time.Parse(time.RFC3339Nano, closedAt.String)
246
+ if parseErr == nil {
247
+ sRow.ClosedAt = &parsedClosed
248
+ }
249
+ }
250
+ sRow.IsActive = sRow.ID == activeID
251
+ sRow.SessionRef = fmt.Sprintf("%s (%s)", sRow.Name, sRow.ID)
252
+ result = append(result, sRow)
253
+ }
254
+ if err := rows.Err(); err != nil {
255
+ return nil, fmt.Errorf("iterate sessions: %w", err)
256
+ }
257
+
258
+ return result, nil
259
+ }
260
+
261
+ func (s *Store) SelectSession(projectID, nameOrID string) (Session, error) {
262
+ session, err := s.findSession(projectID, nameOrID)
263
+ if err != nil {
264
+ return Session{}, err
265
+ }
266
+ if session.Status == "closed" {
267
+ return Session{}, ErrSessionClosed
268
+ }
269
+
270
+ if err := s.SetActiveSession(projectID, session.ID); err != nil {
271
+ return Session{}, err
272
+ }
273
+ session.IsActive = true
274
+ return session, nil
275
+ }
276
+
277
+ func (s *Store) GetSession(projectID, nameOrID string) (Session, error) {
278
+ session, err := s.findSession(projectID, nameOrID)
279
+ if err != nil {
280
+ return Session{}, err
281
+ }
282
+ activeID, _ := s.getMeta(activeSessionKey(projectID))
283
+ session.IsActive = session.ID == activeID
284
+ return session, nil
285
+ }
286
+
287
+ func (s *Store) CloseSession(projectID, nameOrID string) error {
288
+ session, err := s.findSession(projectID, nameOrID)
289
+ if err != nil {
290
+ return err
291
+ }
292
+ if session.Status == "closed" {
293
+ return nil
294
+ }
295
+
296
+ now := time.Now().UTC().Format(time.RFC3339Nano)
297
+ const updateSQL = `UPDATE sessions SET status='closed', closed_at=? WHERE id=?`
298
+ if _, err := s.db.Exec(updateSQL, now, session.ID); err != nil {
299
+ return fmt.Errorf("close session: %w", err)
300
+ }
301
+
302
+ activeID, _ := s.getMeta(activeSessionKey(projectID))
303
+ if activeID == session.ID {
304
+ if session.Name != "default" {
305
+ if fallback, selErr := s.SelectSession(projectID, "default"); selErr == nil {
306
+ _ = s.SetActiveSession(projectID, fallback.ID)
307
+ return nil
308
+ }
309
+ }
310
+ if err := s.setMeta(activeSessionKey(projectID), ""); err != nil {
311
+ return err
312
+ }
313
+ }
314
+
315
+ return nil
316
+ }
317
+
318
+ func (s *Store) GetActiveSession(projectID string) (Session, error) {
319
+ activeID, err := s.getMeta(activeSessionKey(projectID))
320
+ if err != nil {
321
+ return Session{}, err
322
+ }
323
+ activeID = strings.TrimSpace(activeID)
324
+ if activeID == "" {
325
+ return Session{}, ErrSessionNotFound
326
+ }
327
+
328
+ session, err := s.findSession(projectID, activeID)
329
+ if err != nil {
330
+ return Session{}, err
331
+ }
332
+ if session.Status == "closed" {
333
+ return Session{}, ErrSessionClosed
334
+ }
335
+ session.IsActive = true
336
+ return session, nil
337
+ }
338
+
339
+ func (s *Store) SetActiveSession(projectID, sessionID string) error {
340
+ if strings.TrimSpace(sessionID) == "" {
341
+ return s.setMeta(activeSessionKey(projectID), "")
342
+ }
343
+
344
+ session, err := s.findSession(projectID, sessionID)
345
+ if err != nil {
346
+ return err
347
+ }
348
+ if session.Status == "closed" {
349
+ return ErrSessionClosed
350
+ }
351
+ return s.setMeta(activeSessionKey(projectID), session.ID)
352
+ }
353
+
354
+ func (s *Store) SaveRunState(state *models.RunState) error {
355
+ if state == nil {
356
+ return fmt.Errorf("run state cannot be nil")
357
+ }
358
+ if strings.TrimSpace(state.ProjectID) == "" || strings.TrimSpace(state.SessionID) == "" {
359
+ return fmt.Errorf("run state missing project/session metadata")
360
+ }
361
+
362
+ taskJSON, _ := json.Marshal(state.Task)
363
+ taskBriefJSON, _ := json.Marshal(state.TaskBrief)
364
+ planJSON, _ := json.Marshal(state.Plan)
365
+ executionContractJSON, _ := json.Marshal(state.ExecutionContract)
366
+ patchJSON, _ := json.Marshal(state.Patch)
367
+ validationResultsJSON, _ := json.Marshal(state.ValidationResults)
368
+ retryDirectiveJSON, _ := json.Marshal(state.RetryDirective)
369
+ reviewJSON, _ := json.Marshal(state.Review)
370
+ reviewScorecardJSON, _ := json.Marshal(state.ReviewScorecard)
371
+ confidenceJSON, _ := json.Marshal(state.Confidence)
372
+ testFailuresJSON, _ := json.Marshal(state.TestFailures)
373
+ retriesJSON, _ := json.Marshal(state.Retries)
374
+ unresolvedJSON, _ := json.Marshal(state.UnresolvedFailures)
375
+
376
+ completedAt := sql.NullString{}
377
+ if state.CompletedAt != nil {
378
+ completedAt = sql.NullString{String: state.CompletedAt.UTC().Format(time.RFC3339Nano), Valid: true}
379
+ }
380
+
381
+ const upsertRun = `
382
+ INSERT INTO runs(
383
+ id, project_id, session_id, task_json, task_brief_json, status, plan_json, execution_contract_json,
384
+ patch_json, validation_results_json, retry_directive_json, review_json, review_scorecard_json, confidence_json, test_failures_json, test_results, retries_json,
385
+ unresolved_failures_json, best_patch_summary, error, started_at, completed_at
386
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
387
+ ON CONFLICT(id) DO UPDATE SET
388
+ task_brief_json=excluded.task_brief_json,
389
+ status=excluded.status,
390
+ plan_json=excluded.plan_json,
391
+ execution_contract_json=excluded.execution_contract_json,
392
+ patch_json=excluded.patch_json,
393
+ validation_results_json=excluded.validation_results_json,
394
+ retry_directive_json=excluded.retry_directive_json,
395
+ review_json=excluded.review_json,
396
+ review_scorecard_json=excluded.review_scorecard_json,
397
+ confidence_json=excluded.confidence_json,
398
+ test_failures_json=excluded.test_failures_json,
399
+ test_results=excluded.test_results,
400
+ retries_json=excluded.retries_json,
401
+ unresolved_failures_json=excluded.unresolved_failures_json,
402
+ best_patch_summary=excluded.best_patch_summary,
403
+ error=excluded.error,
404
+ completed_at=excluded.completed_at`
405
+
406
+ if _, err := s.db.Exec(upsertRun,
407
+ state.ID,
408
+ state.ProjectID,
409
+ state.SessionID,
410
+ string(taskJSON),
411
+ nullJSON(taskBriefJSON),
412
+ string(state.Status),
413
+ nullJSON(planJSON),
414
+ nullJSON(executionContractJSON),
415
+ nullJSON(patchJSON),
416
+ nullJSON(validationResultsJSON),
417
+ nullJSON(retryDirectiveJSON),
418
+ nullJSON(reviewJSON),
419
+ nullJSON(reviewScorecardJSON),
420
+ nullJSON(confidenceJSON),
421
+ nullJSON(testFailuresJSON),
422
+ nullString(state.TestResults),
423
+ string(retriesJSON),
424
+ nullJSON(unresolvedJSON),
425
+ nullString(state.BestPatchSummary),
426
+ nullString(state.Error),
427
+ state.StartedAt.UTC().Format(time.RFC3339Nano),
428
+ nullStringFromNull(completedAt),
429
+ ); err != nil {
430
+ return fmt.Errorf("upsert run: %w", err)
431
+ }
432
+
433
+ if _, err := s.db.Exec(`DELETE FROM run_logs WHERE run_id = ?`, state.ID); err != nil {
434
+ return fmt.Errorf("clear run logs: %w", err)
435
+ }
436
+
437
+ const insertLog = `INSERT INTO run_logs(run_id, timestamp, actor, step, message) VALUES(?, ?, ?, ?, ?)`
438
+ for _, entry := range state.Logs {
439
+ if _, err := s.db.Exec(insertLog, state.ID, entry.Timestamp.UTC().Format(time.RFC3339Nano), entry.Actor, entry.Step, entry.Message); err != nil {
440
+ return fmt.Errorf("insert run log: %w", err)
441
+ }
442
+ }
443
+
444
+ return nil
445
+ }
446
+
447
+ func (s *Store) ListRunsBySession(sessionID string, limit int) ([]RunRecord, error) {
448
+ return s.ListRunsBySessionFiltered(sessionID, limit, "", "")
449
+ }
450
+
451
+ func (s *Store) ListRunsBySessionFiltered(sessionID string, limit int, statusFilter, containsFilter string) ([]RunRecord, error) {
452
+ if strings.TrimSpace(sessionID) == "" {
453
+ return nil, fmt.Errorf("session id is required")
454
+ }
455
+ if limit <= 0 {
456
+ limit = 20
457
+ }
458
+
459
+ q := `
460
+ SELECT id, session_id, status, task_json, started_at, completed_at, error
461
+ FROM runs
462
+ WHERE session_id = ?`
463
+ args := []any{sessionID}
464
+
465
+ if strings.TrimSpace(statusFilter) != "" {
466
+ q += ` AND status = ?`
467
+ args = append(args, strings.TrimSpace(statusFilter))
468
+ }
469
+
470
+ q += `
471
+ ORDER BY started_at DESC
472
+ LIMIT ?`
473
+ args = append(args, limit)
474
+
475
+ rows, err := s.db.Query(q, args...)
476
+ if err != nil {
477
+ return nil, fmt.Errorf("list runs by session: %w", err)
478
+ }
479
+ defer rows.Close()
480
+
481
+ result := make([]RunRecord, 0)
482
+ for rows.Next() {
483
+ var rec RunRecord
484
+ var taskJSON string
485
+ var startedAt string
486
+ var completedAt sql.NullString
487
+ if err := rows.Scan(&rec.ID, &rec.SessionID, &rec.Status, &taskJSON, &startedAt, &completedAt, &rec.Error); err != nil {
488
+ return nil, fmt.Errorf("scan run row: %w", err)
489
+ }
490
+
491
+ var task models.Task
492
+ if err := json.Unmarshal([]byte(taskJSON), &task); err == nil {
493
+ rec.Task = task.Description
494
+ }
495
+ if rec.Task == "" {
496
+ rec.Task = "(unknown task)"
497
+ }
498
+
499
+ if ts, parseErr := time.Parse(time.RFC3339Nano, startedAt); parseErr == nil {
500
+ rec.StartedAt = ts
501
+ }
502
+ if completedAt.Valid {
503
+ if ts, parseErr := time.Parse(time.RFC3339Nano, completedAt.String); parseErr == nil {
504
+ rec.CompletedAt = &ts
505
+ }
506
+ }
507
+
508
+ if strings.TrimSpace(containsFilter) != "" {
509
+ needle := strings.ToLower(strings.TrimSpace(containsFilter))
510
+ if !strings.Contains(strings.ToLower(rec.Task), needle) {
511
+ continue
512
+ }
513
+ }
514
+
515
+ result = append(result, rec)
516
+ if len(result) >= limit {
517
+ break
518
+ }
519
+ }
520
+ if err := rows.Err(); err != nil {
521
+ return nil, fmt.Errorf("iterate runs by session: %w", err)
522
+ }
523
+
524
+ return result, nil
525
+ }
526
+
527
+ func (s *Store) GetLatestRunStateBySession(sessionID string) (*models.RunState, error) {
528
+ if strings.TrimSpace(sessionID) == "" {
529
+ return nil, fmt.Errorf("session id is required")
530
+ }
531
+
532
+ row := s.db.QueryRow(
533
+ `SELECT id, project_id, session_id, task_json, task_brief_json, status, plan_json, execution_contract_json,
534
+ patch_json, validation_results_json, retry_directive_json, review_json, review_scorecard_json,
535
+ confidence_json, test_failures_json, test_results, retries_json, unresolved_failures_json,
536
+ best_patch_summary, error, started_at, completed_at
537
+ FROM runs
538
+ WHERE session_id = ?
539
+ ORDER BY started_at DESC
540
+ LIMIT 1`,
541
+ sessionID,
542
+ )
543
+
544
+ state, err := scanRunState(row)
545
+ if err != nil {
546
+ if errors.Is(err, sql.ErrNoRows) {
547
+ return nil, fmt.Errorf("run state not found")
548
+ }
549
+ return nil, err
550
+ }
551
+ logs, logErr := s.listRunLogs(state.ID)
552
+ if logErr != nil {
553
+ return nil, logErr
554
+ }
555
+ state.Logs = logs
556
+ return state, nil
557
+ }
558
+
559
+ func (s *Store) GetRunState(projectID, runID string) (*models.RunState, error) {
560
+ if strings.TrimSpace(projectID) == "" {
561
+ return nil, fmt.Errorf("project id is required")
562
+ }
563
+ if strings.TrimSpace(runID) == "" {
564
+ return nil, fmt.Errorf("run id is required")
565
+ }
566
+
567
+ row := s.db.QueryRow(
568
+ `SELECT id, project_id, session_id, task_json, task_brief_json, status, plan_json, execution_contract_json,
569
+ patch_json, validation_results_json, retry_directive_json, review_json, review_scorecard_json,
570
+ confidence_json, test_failures_json, test_results, retries_json, unresolved_failures_json,
571
+ best_patch_summary, error, started_at, completed_at
572
+ FROM runs
573
+ WHERE project_id = ? AND id = ?
574
+ LIMIT 1`,
575
+ projectID,
576
+ runID,
577
+ )
578
+
579
+ state, err := scanRunState(row)
580
+ if err != nil {
581
+ if errors.Is(err, sql.ErrNoRows) {
582
+ return nil, fmt.Errorf("run state not found")
583
+ }
584
+ return nil, err
585
+ }
586
+ logs, logErr := s.listRunLogs(state.ID)
587
+ if logErr != nil {
588
+ return nil, logErr
589
+ }
590
+ state.Logs = logs
591
+ return state, nil
592
+ }
593
+
594
+ func (s *Store) ListRunStatesByProject(projectID string, limit int) ([]*models.RunState, error) {
595
+ if strings.TrimSpace(projectID) == "" {
596
+ return nil, fmt.Errorf("project id is required")
597
+ }
598
+ if limit <= 0 {
599
+ limit = 50
600
+ }
601
+
602
+ rows, err := s.db.Query(
603
+ `SELECT id, project_id, session_id, task_json, task_brief_json, status, plan_json, execution_contract_json,
604
+ patch_json, validation_results_json, retry_directive_json, review_json, review_scorecard_json,
605
+ confidence_json, test_failures_json, test_results, retries_json, unresolved_failures_json,
606
+ best_patch_summary, error, started_at, completed_at
607
+ FROM runs
608
+ WHERE project_id = ?
609
+ ORDER BY started_at DESC
610
+ LIMIT ?`,
611
+ projectID,
612
+ limit,
613
+ )
614
+ if err != nil {
615
+ return nil, fmt.Errorf("list run states: %w", err)
616
+ }
617
+ defer rows.Close()
618
+
619
+ states := make([]*models.RunState, 0)
620
+ for rows.Next() {
621
+ state, scanErr := scanRunState(rows)
622
+ if scanErr != nil {
623
+ return nil, scanErr
624
+ }
625
+ logs, logErr := s.listRunLogs(state.ID)
626
+ if logErr != nil {
627
+ return nil, logErr
628
+ }
629
+ state.Logs = logs
630
+ states = append(states, state)
631
+ }
632
+ if err := rows.Err(); err != nil {
633
+ return nil, fmt.Errorf("iterate run states: %w", err)
634
+ }
635
+
636
+ return states, nil
637
+ }
638
+
639
+ func (s *Store) LoadLatestPatchBySession(sessionID string) (string, error) {
640
+ state, err := s.GetLatestRunStateBySession(sessionID)
641
+ if err != nil {
642
+ return "", err
643
+ }
644
+ if state == nil || state.Patch == nil || strings.TrimSpace(state.Patch.RawDiff) == "" {
645
+ return "", fmt.Errorf("latest patch not found")
646
+ }
647
+ return state.Patch.RawDiff, nil
648
+ }
649
+
650
+ func (s *Store) listRunLogs(runID string) ([]models.LogEntry, error) {
651
+ rows, err := s.db.Query(
652
+ `SELECT timestamp, actor, step, message FROM run_logs WHERE run_id = ? ORDER BY id ASC`,
653
+ runID,
654
+ )
655
+ if err != nil {
656
+ return nil, fmt.Errorf("list run logs: %w", err)
657
+ }
658
+ defer rows.Close()
659
+
660
+ logs := make([]models.LogEntry, 0)
661
+ for rows.Next() {
662
+ var ts string
663
+ var entry models.LogEntry
664
+ if err := rows.Scan(&ts, &entry.Actor, &entry.Step, &entry.Message); err != nil {
665
+ return nil, fmt.Errorf("scan run log: %w", err)
666
+ }
667
+ if parsed, parseErr := time.Parse(time.RFC3339Nano, ts); parseErr == nil {
668
+ entry.Timestamp = parsed
669
+ }
670
+ logs = append(logs, entry)
671
+ }
672
+ if err := rows.Err(); err != nil {
673
+ return nil, fmt.Errorf("iterate run logs: %w", err)
674
+ }
675
+ return logs, nil
676
+ }
677
+
678
+ type runStateScanner interface {
679
+ Scan(dest ...any) error
680
+ }
681
+
682
+ func scanRunState(scanner runStateScanner) (*models.RunState, error) {
683
+ var (
684
+ id string
685
+ projectID string
686
+ sessionID string
687
+ taskJSON string
688
+ taskBriefJSON sql.NullString
689
+ status string
690
+ planJSON sql.NullString
691
+ executionContractJSON sql.NullString
692
+ patchJSON sql.NullString
693
+ validationJSON sql.NullString
694
+ retryDirectiveJSON sql.NullString
695
+ reviewJSON sql.NullString
696
+ reviewScorecardJSON sql.NullString
697
+ confidenceJSON sql.NullString
698
+ testFailuresJSON sql.NullString
699
+ testResults sql.NullString
700
+ retriesJSON string
701
+ unresolvedJSON sql.NullString
702
+ bestPatchSummary sql.NullString
703
+ errorText sql.NullString
704
+ startedAt string
705
+ completedAt sql.NullString
706
+ )
707
+
708
+ if err := scanner.Scan(
709
+ &id,
710
+ &projectID,
711
+ &sessionID,
712
+ &taskJSON,
713
+ &taskBriefJSON,
714
+ &status,
715
+ &planJSON,
716
+ &executionContractJSON,
717
+ &patchJSON,
718
+ &validationJSON,
719
+ &retryDirectiveJSON,
720
+ &reviewJSON,
721
+ &reviewScorecardJSON,
722
+ &confidenceJSON,
723
+ &testFailuresJSON,
724
+ &testResults,
725
+ &retriesJSON,
726
+ &unresolvedJSON,
727
+ &bestPatchSummary,
728
+ &errorText,
729
+ &startedAt,
730
+ &completedAt,
731
+ ); err != nil {
732
+ return nil, err
733
+ }
734
+
735
+ state := &models.RunState{
736
+ ID: id,
737
+ ProjectID: projectID,
738
+ SessionID: sessionID,
739
+ Status: models.RunStatus(status),
740
+ Logs: []models.LogEntry{},
741
+ }
742
+
743
+ if parsed, parseErr := time.Parse(time.RFC3339Nano, startedAt); parseErr == nil {
744
+ state.StartedAt = parsed
745
+ }
746
+ if completedAt.Valid {
747
+ if parsed, parseErr := time.Parse(time.RFC3339Nano, completedAt.String); parseErr == nil {
748
+ state.CompletedAt = &parsed
749
+ }
750
+ }
751
+ state.Error = strings.TrimSpace(errorText.String)
752
+ state.TestResults = strings.TrimSpace(testResults.String)
753
+ state.BestPatchSummary = strings.TrimSpace(bestPatchSummary.String)
754
+
755
+ if err := json.Unmarshal([]byte(taskJSON), &state.Task); err != nil {
756
+ return nil, fmt.Errorf("unmarshal task: %w", err)
757
+ }
758
+
759
+ if strings.TrimSpace(taskBriefJSON.String) != "" {
760
+ state.TaskBrief = &models.TaskBrief{}
761
+ if err := json.Unmarshal([]byte(taskBriefJSON.String), state.TaskBrief); err != nil {
762
+ return nil, fmt.Errorf("unmarshal task brief: %w", err)
763
+ }
764
+ }
765
+ if strings.TrimSpace(planJSON.String) != "" {
766
+ state.Plan = &models.Plan{}
767
+ if err := json.Unmarshal([]byte(planJSON.String), state.Plan); err != nil {
768
+ return nil, fmt.Errorf("unmarshal plan: %w", err)
769
+ }
770
+ }
771
+ if strings.TrimSpace(executionContractJSON.String) != "" {
772
+ state.ExecutionContract = &models.ExecutionContract{}
773
+ if err := json.Unmarshal([]byte(executionContractJSON.String), state.ExecutionContract); err != nil {
774
+ return nil, fmt.Errorf("unmarshal execution contract: %w", err)
775
+ }
776
+ }
777
+ if strings.TrimSpace(patchJSON.String) != "" {
778
+ state.Patch = &models.Patch{}
779
+ if err := json.Unmarshal([]byte(patchJSON.String), state.Patch); err != nil {
780
+ return nil, fmt.Errorf("unmarshal patch: %w", err)
781
+ }
782
+ }
783
+ if strings.TrimSpace(validationJSON.String) != "" {
784
+ if err := json.Unmarshal([]byte(validationJSON.String), &state.ValidationResults); err != nil {
785
+ return nil, fmt.Errorf("unmarshal validation results: %w", err)
786
+ }
787
+ }
788
+ if strings.TrimSpace(retryDirectiveJSON.String) != "" {
789
+ state.RetryDirective = &models.RetryDirective{}
790
+ if err := json.Unmarshal([]byte(retryDirectiveJSON.String), state.RetryDirective); err != nil {
791
+ return nil, fmt.Errorf("unmarshal retry directive: %w", err)
792
+ }
793
+ }
794
+ if strings.TrimSpace(reviewJSON.String) != "" {
795
+ state.Review = &models.ReviewResult{}
796
+ if err := json.Unmarshal([]byte(reviewJSON.String), state.Review); err != nil {
797
+ return nil, fmt.Errorf("unmarshal review: %w", err)
798
+ }
799
+ }
800
+ if strings.TrimSpace(reviewScorecardJSON.String) != "" {
801
+ state.ReviewScorecard = &models.ReviewScorecard{}
802
+ if err := json.Unmarshal([]byte(reviewScorecardJSON.String), state.ReviewScorecard); err != nil {
803
+ return nil, fmt.Errorf("unmarshal review scorecard: %w", err)
804
+ }
805
+ }
806
+ if strings.TrimSpace(confidenceJSON.String) != "" {
807
+ state.Confidence = &models.ConfidenceReport{}
808
+ if err := json.Unmarshal([]byte(confidenceJSON.String), state.Confidence); err != nil {
809
+ return nil, fmt.Errorf("unmarshal confidence: %w", err)
810
+ }
811
+ }
812
+ if strings.TrimSpace(testFailuresJSON.String) != "" {
813
+ if err := json.Unmarshal([]byte(testFailuresJSON.String), &state.TestFailures); err != nil {
814
+ return nil, fmt.Errorf("unmarshal test failures: %w", err)
815
+ }
816
+ }
817
+ if strings.TrimSpace(retriesJSON) != "" {
818
+ if err := json.Unmarshal([]byte(retriesJSON), &state.Retries); err != nil {
819
+ return nil, fmt.Errorf("unmarshal retries: %w", err)
820
+ }
821
+ }
822
+ if strings.TrimSpace(unresolvedJSON.String) != "" {
823
+ if err := json.Unmarshal([]byte(unresolvedJSON.String), &state.UnresolvedFailures); err != nil {
824
+ return nil, fmt.Errorf("unmarshal unresolved failures: %w", err)
825
+ }
826
+ }
827
+
828
+ return state, nil
829
+ }
830
+
831
+ func (s *Store) CreateMessageWithParts(message SessionMessage, parts []SessionPart) (SessionMessage, []SessionPart, error) {
832
+ if strings.TrimSpace(message.SessionID) == "" {
833
+ return SessionMessage{}, nil, fmt.Errorf("session id is required")
834
+ }
835
+ message.Role = strings.ToLower(strings.TrimSpace(message.Role))
836
+ if message.Role != "user" && message.Role != "assistant" && message.Role != "system" {
837
+ return SessionMessage{}, nil, fmt.Errorf("invalid message role: %s", message.Role)
838
+ }
839
+ if strings.TrimSpace(message.ID) == "" {
840
+ message.ID = newID("msg")
841
+ }
842
+ if message.CreatedAt.IsZero() {
843
+ message.CreatedAt = time.Now().UTC()
844
+ } else {
845
+ message.CreatedAt = message.CreatedAt.UTC()
846
+ }
847
+
848
+ tx, err := s.db.Begin()
849
+ if err != nil {
850
+ return SessionMessage{}, nil, fmt.Errorf("begin message transaction: %w", err)
851
+ }
852
+ defer func() { _ = tx.Rollback() }()
853
+
854
+ parentID := strings.TrimSpace(message.ParentID)
855
+ if _, err := tx.Exec(
856
+ `INSERT INTO session_messages(id, session_id, role, parent_id, provider_id, model_id, finish_reason, error, created_at)
857
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
858
+ message.ID,
859
+ message.SessionID,
860
+ message.Role,
861
+ nullString(parentID),
862
+ nullString(message.ProviderID),
863
+ nullString(message.ModelID),
864
+ nullString(message.FinishReason),
865
+ nullString(message.Error),
866
+ message.CreatedAt.Format(time.RFC3339Nano),
867
+ ); err != nil {
868
+ return SessionMessage{}, nil, fmt.Errorf("insert session message: %w", err)
869
+ }
870
+
871
+ inserted := make([]SessionPart, 0, len(parts))
872
+ for _, part := range parts {
873
+ partType := strings.ToLower(strings.TrimSpace(part.Type))
874
+ if partType == "" {
875
+ return SessionMessage{}, nil, fmt.Errorf("part type is required")
876
+ }
877
+ if strings.TrimSpace(part.ID) == "" {
878
+ part.ID = newID("part")
879
+ }
880
+ part.MessageID = message.ID
881
+ now := time.Now().UTC()
882
+ if part.CreatedAt.IsZero() {
883
+ part.CreatedAt = now
884
+ } else {
885
+ part.CreatedAt = part.CreatedAt.UTC()
886
+ }
887
+ if part.UpdatedAt.IsZero() {
888
+ part.UpdatedAt = part.CreatedAt
889
+ } else {
890
+ part.UpdatedAt = part.UpdatedAt.UTC()
891
+ }
892
+ part.Type = partType
893
+
894
+ compacted := 0
895
+ if part.Compacted {
896
+ compacted = 1
897
+ }
898
+
899
+ if _, err := tx.Exec(
900
+ `INSERT INTO session_parts(id, message_id, type, payload_json, compacted, created_at, updated_at)
901
+ VALUES(?, ?, ?, ?, ?, ?, ?)`,
902
+ part.ID,
903
+ part.MessageID,
904
+ part.Type,
905
+ nullString(part.Payload),
906
+ compacted,
907
+ part.CreatedAt.Format(time.RFC3339Nano),
908
+ part.UpdatedAt.Format(time.RFC3339Nano),
909
+ ); err != nil {
910
+ return SessionMessage{}, nil, fmt.Errorf("insert session part: %w", err)
911
+ }
912
+
913
+ inserted = append(inserted, part)
914
+ }
915
+
916
+ if err := tx.Commit(); err != nil {
917
+ return SessionMessage{}, nil, fmt.Errorf("commit message transaction: %w", err)
918
+ }
919
+
920
+ return message, inserted, nil
921
+ }
922
+
923
+ func (s *Store) ListSessionMessages(sessionID string, limit int) ([]SessionMessage, error) {
924
+ if strings.TrimSpace(sessionID) == "" {
925
+ return nil, fmt.Errorf("session id is required")
926
+ }
927
+ if limit <= 0 {
928
+ limit = 200
929
+ }
930
+
931
+ rows, err := s.db.Query(
932
+ `SELECT id, session_id, role, parent_id, provider_id, model_id, finish_reason, error, created_at
933
+ FROM session_messages
934
+ WHERE session_id = ?
935
+ ORDER BY created_at ASC, id ASC
936
+ LIMIT ?`,
937
+ sessionID,
938
+ limit,
939
+ )
940
+ if err != nil {
941
+ return nil, fmt.Errorf("list session messages: %w", err)
942
+ }
943
+ defer rows.Close()
944
+
945
+ messages := make([]SessionMessage, 0)
946
+ for rows.Next() {
947
+ var item SessionMessage
948
+ var parentID sql.NullString
949
+ var providerID sql.NullString
950
+ var modelID sql.NullString
951
+ var finishReason sql.NullString
952
+ var errText sql.NullString
953
+ var createdAt string
954
+ if err := rows.Scan(&item.ID, &item.SessionID, &item.Role, &parentID, &providerID, &modelID, &finishReason, &errText, &createdAt); err != nil {
955
+ return nil, fmt.Errorf("scan session message: %w", err)
956
+ }
957
+ item.ParentID = strings.TrimSpace(parentID.String)
958
+ item.ProviderID = strings.TrimSpace(providerID.String)
959
+ item.ModelID = strings.TrimSpace(modelID.String)
960
+ item.FinishReason = strings.TrimSpace(finishReason.String)
961
+ item.Error = strings.TrimSpace(errText.String)
962
+ if ts, parseErr := time.Parse(time.RFC3339Nano, createdAt); parseErr == nil {
963
+ item.CreatedAt = ts
964
+ }
965
+ messages = append(messages, item)
966
+ }
967
+ if err := rows.Err(); err != nil {
968
+ return nil, fmt.Errorf("iterate session messages: %w", err)
969
+ }
970
+
971
+ return messages, nil
972
+ }
973
+
974
+ func (s *Store) ListSessionParts(messageID string) ([]SessionPart, error) {
975
+ if strings.TrimSpace(messageID) == "" {
976
+ return nil, fmt.Errorf("message id is required")
977
+ }
978
+
979
+ rows, err := s.db.Query(
980
+ `SELECT id, message_id, type, payload_json, compacted, created_at, updated_at
981
+ FROM session_parts
982
+ WHERE message_id = ?
983
+ ORDER BY created_at ASC, id ASC`,
984
+ messageID,
985
+ )
986
+ if err != nil {
987
+ return nil, fmt.Errorf("list session parts: %w", err)
988
+ }
989
+ defer rows.Close()
990
+
991
+ parts := make([]SessionPart, 0)
992
+ for rows.Next() {
993
+ var item SessionPart
994
+ var compacted int
995
+ var payload sql.NullString
996
+ var createdAt string
997
+ var updatedAt string
998
+ if err := rows.Scan(&item.ID, &item.MessageID, &item.Type, &payload, &compacted, &createdAt, &updatedAt); err != nil {
999
+ return nil, fmt.Errorf("scan session part: %w", err)
1000
+ }
1001
+ item.Payload = payload.String
1002
+ item.Compacted = compacted != 0
1003
+ if ts, parseErr := time.Parse(time.RFC3339Nano, createdAt); parseErr == nil {
1004
+ item.CreatedAt = ts
1005
+ }
1006
+ if ts, parseErr := time.Parse(time.RFC3339Nano, updatedAt); parseErr == nil {
1007
+ item.UpdatedAt = ts
1008
+ }
1009
+ parts = append(parts, item)
1010
+ }
1011
+ if err := rows.Err(); err != nil {
1012
+ return nil, fmt.Errorf("iterate session parts: %w", err)
1013
+ }
1014
+
1015
+ return parts, nil
1016
+ }
1017
+
1018
+ func (s *Store) UpsertSessionSummary(sessionID, summaryText string) error {
1019
+ if strings.TrimSpace(sessionID) == "" {
1020
+ return fmt.Errorf("session id is required")
1021
+ }
1022
+ now := time.Now().UTC().Format(time.RFC3339Nano)
1023
+ _, err := s.db.Exec(
1024
+ `INSERT INTO session_summaries(session_id, summary_text, updated_at)
1025
+ VALUES(?, ?, ?)
1026
+ ON CONFLICT(session_id) DO UPDATE SET summary_text=excluded.summary_text, updated_at=excluded.updated_at`,
1027
+ sessionID,
1028
+ nullString(summaryText),
1029
+ now,
1030
+ )
1031
+ if err != nil {
1032
+ return fmt.Errorf("upsert session summary: %w", err)
1033
+ }
1034
+ return nil
1035
+ }
1036
+
1037
+ func (s *Store) GetSessionSummary(sessionID string) (*SessionSummary, error) {
1038
+ if strings.TrimSpace(sessionID) == "" {
1039
+ return nil, fmt.Errorf("session id is required")
1040
+ }
1041
+ var summary SessionSummary
1042
+ var updatedAt string
1043
+ if err := s.db.QueryRow(
1044
+ `SELECT session_id, summary_text, updated_at FROM session_summaries WHERE session_id = ?`,
1045
+ sessionID,
1046
+ ).Scan(&summary.SessionID, &summary.SummaryText, &updatedAt); err != nil {
1047
+ if errors.Is(err, sql.ErrNoRows) {
1048
+ return nil, nil
1049
+ }
1050
+ return nil, fmt.Errorf("query session summary: %w", err)
1051
+ }
1052
+ if ts, parseErr := time.Parse(time.RFC3339Nano, updatedAt); parseErr == nil {
1053
+ summary.UpdatedAt = ts
1054
+ }
1055
+ return &summary, nil
1056
+ }
1057
+
1058
+ func (s *Store) UpsertSessionMetrics(metrics SessionMetrics) error {
1059
+ if strings.TrimSpace(metrics.SessionID) == "" {
1060
+ return fmt.Errorf("session id is required")
1061
+ }
1062
+ metrics.UpdatedAt = time.Now().UTC()
1063
+ _, err := s.db.Exec(
1064
+ `INSERT INTO session_metrics(session_id, input_tokens, output_tokens, total_cost, turn_count, last_message_id, updated_at)
1065
+ VALUES(?, ?, ?, ?, ?, ?, ?)
1066
+ ON CONFLICT(session_id) DO UPDATE SET
1067
+ input_tokens=excluded.input_tokens,
1068
+ output_tokens=excluded.output_tokens,
1069
+ total_cost=excluded.total_cost,
1070
+ turn_count=excluded.turn_count,
1071
+ last_message_id=excluded.last_message_id,
1072
+ updated_at=excluded.updated_at`,
1073
+ metrics.SessionID,
1074
+ metrics.InputTokens,
1075
+ metrics.OutputTokens,
1076
+ metrics.TotalCost,
1077
+ metrics.TurnCount,
1078
+ nullString(metrics.LastMessageID),
1079
+ metrics.UpdatedAt.Format(time.RFC3339Nano),
1080
+ )
1081
+ if err != nil {
1082
+ return fmt.Errorf("upsert session metrics: %w", err)
1083
+ }
1084
+ return nil
1085
+ }
1086
+
1087
+ func (s *Store) GetSessionMetrics(sessionID string) (*SessionMetrics, error) {
1088
+ if strings.TrimSpace(sessionID) == "" {
1089
+ return nil, fmt.Errorf("session id is required")
1090
+ }
1091
+ var metrics SessionMetrics
1092
+ var lastMessageID sql.NullString
1093
+ var updatedAt string
1094
+ if err := s.db.QueryRow(
1095
+ `SELECT session_id, input_tokens, output_tokens, total_cost, turn_count, last_message_id, updated_at
1096
+ FROM session_metrics WHERE session_id = ?`,
1097
+ sessionID,
1098
+ ).Scan(
1099
+ &metrics.SessionID,
1100
+ &metrics.InputTokens,
1101
+ &metrics.OutputTokens,
1102
+ &metrics.TotalCost,
1103
+ &metrics.TurnCount,
1104
+ &lastMessageID,
1105
+ &updatedAt,
1106
+ ); err != nil {
1107
+ if errors.Is(err, sql.ErrNoRows) {
1108
+ return nil, nil
1109
+ }
1110
+ return nil, fmt.Errorf("query session metrics: %w", err)
1111
+ }
1112
+ metrics.LastMessageID = strings.TrimSpace(lastMessageID.String)
1113
+ if ts, parseErr := time.Parse(time.RFC3339Nano, updatedAt); parseErr == nil {
1114
+ metrics.UpdatedAt = ts
1115
+ }
1116
+ return &metrics, nil
1117
+ }
1118
+
1119
+ func (s *Store) CompactSessionParts(sessionID string, keepLastMessages int) (int64, error) {
1120
+ if strings.TrimSpace(sessionID) == "" {
1121
+ return 0, fmt.Errorf("session id is required")
1122
+ }
1123
+ if keepLastMessages <= 0 {
1124
+ keepLastMessages = 12
1125
+ }
1126
+ now := time.Now().UTC().Format(time.RFC3339Nano)
1127
+ result, err := s.db.Exec(
1128
+ `WITH keep AS (
1129
+ SELECT id
1130
+ FROM session_messages
1131
+ WHERE session_id = ?
1132
+ ORDER BY created_at DESC, id DESC
1133
+ LIMIT ?
1134
+ )
1135
+ UPDATE session_parts
1136
+ SET compacted = 1,
1137
+ payload_json = '[Old content compacted]',
1138
+ updated_at = ?
1139
+ WHERE compacted = 0
1140
+ AND message_id IN (
1141
+ SELECT id
1142
+ FROM session_messages
1143
+ WHERE session_id = ?
1144
+ AND id NOT IN (SELECT id FROM keep)
1145
+ )`,
1146
+ sessionID,
1147
+ keepLastMessages,
1148
+ now,
1149
+ sessionID,
1150
+ )
1151
+ if err != nil {
1152
+ return 0, fmt.Errorf("compact session parts: %w", err)
1153
+ }
1154
+ affected, err := result.RowsAffected()
1155
+ if err != nil {
1156
+ return 0, fmt.Errorf("read compacted rows: %w", err)
1157
+ }
1158
+ return affected, nil
1159
+ }
1160
+
1161
+ func (s *Store) migrate() error {
1162
+ const createMigrations = `
1163
+ CREATE TABLE IF NOT EXISTS schema_migrations(
1164
+ version INTEGER PRIMARY KEY,
1165
+ applied_at TEXT NOT NULL
1166
+ );`
1167
+ if _, err := s.db.Exec(createMigrations); err != nil {
1168
+ return fmt.Errorf("create schema_migrations: %w", err)
1169
+ }
1170
+
1171
+ var count int
1172
+ if err := s.db.QueryRow(`SELECT COUNT(1) FROM schema_migrations WHERE version = 1`).Scan(&count); err != nil {
1173
+ return fmt.Errorf("check schema version: %w", err)
1174
+ }
1175
+ if count > 0 {
1176
+ if err := s.ensureRunColumns(); err != nil {
1177
+ return err
1178
+ }
1179
+ return s.ensureSessionStoreTables()
1180
+ }
1181
+
1182
+ tx, err := s.db.Begin()
1183
+ if err != nil {
1184
+ return fmt.Errorf("begin migration transaction: %w", err)
1185
+ }
1186
+ defer func() { _ = tx.Rollback() }()
1187
+
1188
+ stmts := []string{
1189
+ `CREATE TABLE IF NOT EXISTS projects(
1190
+ id TEXT PRIMARY KEY,
1191
+ name TEXT NOT NULL,
1192
+ repo_root TEXT NOT NULL UNIQUE,
1193
+ created_at TEXT NOT NULL,
1194
+ updated_at TEXT NOT NULL
1195
+ );`,
1196
+ `CREATE TABLE IF NOT EXISTS sessions(
1197
+ id TEXT PRIMARY KEY,
1198
+ project_id TEXT NOT NULL REFERENCES projects(id),
1199
+ name TEXT NOT NULL,
1200
+ status TEXT NOT NULL,
1201
+ worktree_path TEXT,
1202
+ created_at TEXT NOT NULL,
1203
+ closed_at TEXT,
1204
+ UNIQUE(project_id, name)
1205
+ );`,
1206
+ `CREATE TABLE IF NOT EXISTS runs(
1207
+ id TEXT PRIMARY KEY,
1208
+ project_id TEXT NOT NULL REFERENCES projects(id),
1209
+ session_id TEXT NOT NULL REFERENCES sessions(id),
1210
+ task_json TEXT NOT NULL,
1211
+ task_brief_json TEXT,
1212
+ status TEXT NOT NULL,
1213
+ plan_json TEXT,
1214
+ execution_contract_json TEXT,
1215
+ patch_json TEXT,
1216
+ validation_results_json TEXT,
1217
+ retry_directive_json TEXT,
1218
+ review_json TEXT,
1219
+ review_scorecard_json TEXT,
1220
+ confidence_json TEXT,
1221
+ test_failures_json TEXT,
1222
+ test_results TEXT,
1223
+ retries_json TEXT,
1224
+ unresolved_failures_json TEXT,
1225
+ best_patch_summary TEXT,
1226
+ error TEXT,
1227
+ started_at TEXT NOT NULL,
1228
+ completed_at TEXT
1229
+ );`,
1230
+ `CREATE TABLE IF NOT EXISTS run_logs(
1231
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1232
+ run_id TEXT NOT NULL REFERENCES runs(id),
1233
+ timestamp TEXT NOT NULL,
1234
+ actor TEXT NOT NULL,
1235
+ step TEXT NOT NULL,
1236
+ message TEXT NOT NULL
1237
+ );`,
1238
+ `CREATE TABLE IF NOT EXISTS meta(
1239
+ key TEXT PRIMARY KEY,
1240
+ value TEXT NOT NULL
1241
+ );`,
1242
+ `CREATE TABLE IF NOT EXISTS session_messages(
1243
+ id TEXT PRIMARY KEY,
1244
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
1245
+ role TEXT NOT NULL,
1246
+ parent_id TEXT,
1247
+ provider_id TEXT,
1248
+ model_id TEXT,
1249
+ finish_reason TEXT,
1250
+ error TEXT,
1251
+ created_at TEXT NOT NULL
1252
+ );`,
1253
+ `CREATE INDEX IF NOT EXISTS idx_session_messages_session_created
1254
+ ON session_messages(session_id, created_at, id);`,
1255
+ `CREATE TABLE IF NOT EXISTS session_parts(
1256
+ id TEXT PRIMARY KEY,
1257
+ message_id TEXT NOT NULL REFERENCES session_messages(id) ON DELETE CASCADE,
1258
+ type TEXT NOT NULL,
1259
+ payload_json TEXT,
1260
+ compacted INTEGER NOT NULL DEFAULT 0,
1261
+ created_at TEXT NOT NULL,
1262
+ updated_at TEXT NOT NULL
1263
+ );`,
1264
+ `CREATE INDEX IF NOT EXISTS idx_session_parts_message_created
1265
+ ON session_parts(message_id, created_at, id);`,
1266
+ `CREATE TABLE IF NOT EXISTS session_summaries(
1267
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
1268
+ summary_text TEXT NOT NULL,
1269
+ updated_at TEXT NOT NULL
1270
+ );`,
1271
+ `CREATE TABLE IF NOT EXISTS session_metrics(
1272
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
1273
+ input_tokens INTEGER NOT NULL DEFAULT 0,
1274
+ output_tokens INTEGER NOT NULL DEFAULT 0,
1275
+ total_cost REAL NOT NULL DEFAULT 0,
1276
+ turn_count INTEGER NOT NULL DEFAULT 0,
1277
+ last_message_id TEXT,
1278
+ updated_at TEXT NOT NULL
1279
+ );`,
1280
+ }
1281
+
1282
+ for _, stmt := range stmts {
1283
+ if _, err := tx.Exec(stmt); err != nil {
1284
+ return fmt.Errorf("apply migration v1: %w", err)
1285
+ }
1286
+ }
1287
+
1288
+ if _, err := tx.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
1289
+ return fmt.Errorf("record migration version: %w", err)
1290
+ }
1291
+
1292
+ if err := tx.Commit(); err != nil {
1293
+ return fmt.Errorf("commit migration transaction: %w", err)
1294
+ }
1295
+ if err := s.ensureRunColumns(); err != nil {
1296
+ return err
1297
+ }
1298
+ return s.ensureSessionStoreTables()
1299
+ }
1300
+
1301
+ func (s *Store) ensureSessionStoreTables() error {
1302
+ stmts := []string{
1303
+ `CREATE TABLE IF NOT EXISTS session_messages(
1304
+ id TEXT PRIMARY KEY,
1305
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
1306
+ role TEXT NOT NULL,
1307
+ parent_id TEXT,
1308
+ provider_id TEXT,
1309
+ model_id TEXT,
1310
+ finish_reason TEXT,
1311
+ error TEXT,
1312
+ created_at TEXT NOT NULL
1313
+ );`,
1314
+ `CREATE INDEX IF NOT EXISTS idx_session_messages_session_created
1315
+ ON session_messages(session_id, created_at, id);`,
1316
+ `CREATE TABLE IF NOT EXISTS session_parts(
1317
+ id TEXT PRIMARY KEY,
1318
+ message_id TEXT NOT NULL REFERENCES session_messages(id) ON DELETE CASCADE,
1319
+ type TEXT NOT NULL,
1320
+ payload_json TEXT,
1321
+ compacted INTEGER NOT NULL DEFAULT 0,
1322
+ created_at TEXT NOT NULL,
1323
+ updated_at TEXT NOT NULL
1324
+ );`,
1325
+ `CREATE INDEX IF NOT EXISTS idx_session_parts_message_created
1326
+ ON session_parts(message_id, created_at, id);`,
1327
+ `CREATE TABLE IF NOT EXISTS session_summaries(
1328
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
1329
+ summary_text TEXT NOT NULL,
1330
+ updated_at TEXT NOT NULL
1331
+ );`,
1332
+ `CREATE TABLE IF NOT EXISTS session_metrics(
1333
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
1334
+ input_tokens INTEGER NOT NULL DEFAULT 0,
1335
+ output_tokens INTEGER NOT NULL DEFAULT 0,
1336
+ total_cost REAL NOT NULL DEFAULT 0,
1337
+ turn_count INTEGER NOT NULL DEFAULT 0,
1338
+ last_message_id TEXT,
1339
+ updated_at TEXT NOT NULL
1340
+ );`,
1341
+ }
1342
+
1343
+ for _, stmt := range stmts {
1344
+ if _, err := s.db.Exec(stmt); err != nil {
1345
+ return fmt.Errorf("ensure session store tables: %w", err)
1346
+ }
1347
+ }
1348
+
1349
+ return nil
1350
+ }
1351
+
1352
+ func (s *Store) ensureRunColumns() error {
1353
+ existing, err := s.runColumns()
1354
+ if err != nil {
1355
+ return err
1356
+ }
1357
+
1358
+ required := map[string]string{
1359
+ "task_brief_json": "TEXT",
1360
+ "execution_contract_json": "TEXT",
1361
+ "validation_results_json": "TEXT",
1362
+ "retry_directive_json": "TEXT",
1363
+ "review_scorecard_json": "TEXT",
1364
+ "confidence_json": "TEXT",
1365
+ "test_failures_json": "TEXT",
1366
+ }
1367
+
1368
+ for column, definition := range required {
1369
+ if _, ok := existing[column]; ok {
1370
+ continue
1371
+ }
1372
+ statement := fmt.Sprintf("ALTER TABLE runs ADD COLUMN %s %s", column, definition)
1373
+ if _, err := s.db.Exec(statement); err != nil {
1374
+ return fmt.Errorf("add runs column %s: %w", column, err)
1375
+ }
1376
+ }
1377
+
1378
+ return nil
1379
+ }
1380
+
1381
+ func (s *Store) runColumns() (map[string]struct{}, error) {
1382
+ rows, err := s.db.Query("PRAGMA table_info(runs)")
1383
+ if err != nil {
1384
+ return nil, fmt.Errorf("query runs table info: %w", err)
1385
+ }
1386
+ defer rows.Close()
1387
+
1388
+ columns := make(map[string]struct{})
1389
+ for rows.Next() {
1390
+ var cid int
1391
+ var name string
1392
+ var columnType string
1393
+ var notNull int
1394
+ var defaultValue sql.NullString
1395
+ var pk int
1396
+ if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk); err != nil {
1397
+ return nil, fmt.Errorf("scan runs table info: %w", err)
1398
+ }
1399
+ columns[name] = struct{}{}
1400
+ }
1401
+ if err := rows.Err(); err != nil {
1402
+ return nil, fmt.Errorf("iterate runs table info: %w", err)
1403
+ }
1404
+ return columns, nil
1405
+ }
1406
+
1407
+ func (s *Store) findSession(projectID, nameOrID string) (Session, error) {
1408
+ nameOrID = strings.TrimSpace(nameOrID)
1409
+ const q = `
1410
+ SELECT id, project_id, name, status, worktree_path, created_at, closed_at
1411
+ FROM sessions
1412
+ WHERE project_id = ? AND (id = ? OR name = ?)
1413
+ LIMIT 1`
1414
+
1415
+ var sess Session
1416
+ var createdAt string
1417
+ var closedAt sql.NullString
1418
+ if err := s.db.QueryRow(q, projectID, nameOrID, nameOrID).Scan(
1419
+ &sess.ID,
1420
+ &sess.ProjectID,
1421
+ &sess.Name,
1422
+ &sess.Status,
1423
+ &sess.Worktree,
1424
+ &createdAt,
1425
+ &closedAt,
1426
+ ); err != nil {
1427
+ if errors.Is(err, sql.ErrNoRows) {
1428
+ return Session{}, ErrSessionNotFound
1429
+ }
1430
+ return Session{}, fmt.Errorf("query session: %w", err)
1431
+ }
1432
+
1433
+ created, parseErr := time.Parse(time.RFC3339Nano, createdAt)
1434
+ if parseErr == nil {
1435
+ sess.CreatedAt = created
1436
+ }
1437
+ if closedAt.Valid {
1438
+ if closed, cErr := time.Parse(time.RFC3339Nano, closedAt.String); cErr == nil {
1439
+ sess.ClosedAt = &closed
1440
+ }
1441
+ }
1442
+ return sess, nil
1443
+ }
1444
+
1445
+ func (s *Store) getMeta(key string) (string, error) {
1446
+ var value string
1447
+ if err := s.db.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&value); err != nil {
1448
+ if errors.Is(err, sql.ErrNoRows) {
1449
+ return "", ErrSessionNotFound
1450
+ }
1451
+ return "", fmt.Errorf("query meta: %w", err)
1452
+ }
1453
+ return value, nil
1454
+ }
1455
+
1456
+ func (s *Store) setMeta(key, value string) error {
1457
+ const upsert = `
1458
+ INSERT INTO meta(key, value) VALUES(?, ?)
1459
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value`
1460
+ if _, err := s.db.Exec(upsert, key, value); err != nil {
1461
+ return fmt.Errorf("upsert meta: %w", err)
1462
+ }
1463
+ return nil
1464
+ }
1465
+
1466
+ func activeSessionKey(projectID string) string {
1467
+ return activeSessionKeyNS + projectID
1468
+ }
1469
+
1470
+ func newID(prefix string) string {
1471
+ seq := atomic.AddUint64(&idCounter, 1)
1472
+ return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), seq)
1473
+ }
1474
+
1475
+ func nullString(v string) string {
1476
+ trimmed := strings.TrimSpace(v)
1477
+ if trimmed == "" {
1478
+ return ""
1479
+ }
1480
+ return v
1481
+ }
1482
+
1483
+ func nullJSON(data []byte) string {
1484
+ if len(data) == 0 {
1485
+ return ""
1486
+ }
1487
+ if string(data) == "null" {
1488
+ return ""
1489
+ }
1490
+ return string(data)
1491
+ }
1492
+
1493
+ func nullStringFromNull(v sql.NullString) string {
1494
+ if !v.Valid {
1495
+ return ""
1496
+ }
1497
+ return v.String
1498
+ }