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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- 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, ¬Null, &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
|
+
}
|