返回 DeepSeek-Reasonix
store.go
1 package draftstate
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "net/url"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "sync"
14 "time"
15
16 sqlite "modernc.org/sqlite"
17 )
18
19 const SchemaVersion = 4
20 const SnapshotVersion = 5
21
22 var (
23 ErrConflict = errors.New("session draft revision conflict")
24 ErrNotFound = errors.New("session draft not found")
25 ErrConverted = errors.New("session draft was converted")
26 ErrOperationConflict = errors.New("session draft submission conflicts with the active operation")
27 ErrOperationNotFound = errors.New("session draft submission not found")
28 ErrUnsupportedVersion = errors.New("session draft schema version is unsupported")
29 )
30
31 type Draft struct {
32 ID string
33 WorkspaceID string
34 Scope string
35 WorkspaceRoot string
36 Revision uint64
37 ContentJSON string
38 SettingsJSON string
39 Status string
40 UpdatedAt time.Time
41 }
42
43 type Operation struct {
44 RequestID string
45 SourceDigest string
46 Revision uint64
47 ExecutionJSON string
48 ID string
49 DraftID string
50 WorkspaceID string
51 DraftRevision uint64
52 SessionID string
53 TopicID string
54 SubmissionID string
55 Fingerprint string
56 RequestJSON string
57 Phase string
58 Error string
59 CreatedAt time.Time
60 UpdatedAt time.Time
61 }
62
63 type Store struct {
64 path string
65 mu sync.Mutex
66 db *sql.DB
67 }
68
69 func New(path string) *Store { return &Store{path: filepath.Clean(path)} }
70
71 func (s *Store) Path() string {
72 if s == nil || s.path == "." {
73 return ""
74 }
75 return s.path
76 }
77
78 func (s *Store) Close() error {
79 if s == nil {
80 return nil
81 }
82 s.mu.Lock()
83 defer s.mu.Unlock()
84 if s.db == nil {
85 return nil
86 }
87 err := s.db.Close()
88 s.db = nil
89 return err
90 }
91
92 func (s *Store) openLocked() error {
93 if s.db != nil {
94 return nil
95 }
96 if s == nil || s.path == "" || s.path == "." {
97 return errors.New("session draft database path is unavailable")
98 }
99 if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
100 return err
101 }
102 db, err := sql.Open("sqlite", draftFileDSN(s.path))
103 if err != nil {
104 return err
105 }
106 db.SetMaxOpenConns(1)
107 var version int
108 if err = db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
109 _ = db.Close()
110 return err
111 }
112 if version > SchemaVersion {
113 _ = db.Close()
114 return fmt.Errorf("%w: %d", ErrUnsupportedVersion, version)
115 }
116 // Even changing journal mode writes the database header. Check the version
117 // first so an older binary leaves an unknown future file byte-for-byte intact.
118 for _, statement := range []string{
119 `PRAGMA busy_timeout = 5000`,
120 `PRAGMA journal_mode = WAL`,
121 `PRAGMA foreign_keys = ON`,
122 } {
123 if _, err = db.Exec(statement); err != nil {
124 _ = db.Close()
125 return err
126 }
127 }
128 if version == 0 {
129 if err = initialize(db); err != nil {
130 _ = db.Close()
131 return err
132 }
133 } else {
134 if version == 1 {
135 if err = migrateV1ToV2(db); err != nil {
136 _ = db.Close()
137 return err
138 }
139 version = 2
140 }
141 if version == 2 {
142 if err = migrateV2ToV3(db); err != nil {
143 _ = db.Close()
144 return err
145 }
146 version = 3
147 }
148 if version == 3 {
149 if err = migrateV3ToV4(db); err != nil {
150 _ = db.Close()
151 return err
152 }
153 }
154 }
155 s.db = db
156 return nil
157 }
158
159 func initialize(db *sql.DB) error {
160 tx, err := db.Begin()
161 if err != nil {
162 return err
163 }
164 defer rollbackTransaction(tx)
165 statements := []string{
166 `CREATE TABLE IF NOT EXISTS drafts (
167 id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, scope TEXT NOT NULL,
168 workspace_root TEXT NOT NULL, revision INTEGER NOT NULL,
169 content_json TEXT NOT NULL, settings_json TEXT NOT NULL,
170 status TEXT NOT NULL, updated_at INTEGER NOT NULL
171 )`,
172 `CREATE UNIQUE INDEX IF NOT EXISTS one_active_draft_per_workspace ON drafts(workspace_id) WHERE status = 'active'`,
173 `CREATE TABLE IF NOT EXISTS operations (
174 id TEXT PRIMARY KEY, draft_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
175 draft_revision INTEGER NOT NULL, session_id TEXT NOT NULL,
176 topic_id TEXT NOT NULL,
177 submission_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
178 request_json TEXT NOT NULL, phase TEXT NOT NULL, error TEXT NOT NULL,
179 created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
180 request_id TEXT NOT NULL DEFAULT '', source_digest TEXT NOT NULL DEFAULT '',
181 operation_revision INTEGER NOT NULL DEFAULT 1, execution_json TEXT NOT NULL DEFAULT ''
182 )`,
183 `CREATE INDEX IF NOT EXISTS operations_by_draft ON operations(draft_id, updated_at DESC)`,
184 `CREATE UNIQUE INDEX IF NOT EXISTS operations_by_request ON operations(draft_id, request_id) WHERE request_id <> ''`,
185 `CREATE TABLE IF NOT EXISTS conflicts (
186 id INTEGER PRIMARY KEY AUTOINCREMENT, draft_id TEXT NOT NULL,
187 expected_revision INTEGER NOT NULL, actual_revision INTEGER NOT NULL,
188 content_json TEXT NOT NULL, settings_json TEXT NOT NULL, created_at INTEGER NOT NULL
189 )`,
190 `CREATE TABLE IF NOT EXISTS restore_state (slot TEXT PRIMARY KEY, draft_id TEXT NOT NULL, updated_at INTEGER NOT NULL)`,
191 fmt.Sprintf(`PRAGMA user_version = %d`, SchemaVersion),
192 }
193 for _, statement := range statements {
194 if _, err := tx.Exec(statement); err != nil {
195 return err
196 }
197 }
198 return tx.Commit()
199 }
200
201 func migrateV3ToV4(db *sql.DB) error {
202 tx, err := db.Begin()
203 if err != nil {
204 return err
205 }
206 defer rollbackTransaction(tx)
207 for _, query := range []string{
208 `ALTER TABLE operations ADD COLUMN request_id TEXT NOT NULL DEFAULT ''`,
209 `ALTER TABLE operations ADD COLUMN source_digest TEXT NOT NULL DEFAULT ''`,
210 `ALTER TABLE operations ADD COLUMN operation_revision INTEGER NOT NULL DEFAULT 1`,
211 `ALTER TABLE operations ADD COLUMN execution_json TEXT NOT NULL DEFAULT ''`,
212 `CREATE UNIQUE INDEX operations_by_request ON operations(draft_id, request_id) WHERE request_id <> ''`,
213 `PRAGMA user_version = 4`,
214 } {
215 if _, err := tx.Exec(query); err != nil {
216 return err
217 }
218 }
219 return tx.Commit()
220 }
221
222 func migrateV1ToV2(db *sql.DB) error {
223 tx, err := db.Begin()
224 if err != nil {
225 return err
226 }
227 defer rollbackTransaction(tx)
228 if _, err := tx.Exec(`ALTER TABLE operations ADD COLUMN topic_id TEXT NOT NULL DEFAULT ''`); err != nil {
229 return err
230 }
231 if _, err := tx.Exec(`PRAGMA user_version = 2`); err != nil {
232 return err
233 }
234 return tx.Commit()
235 }
236
237 // v3 makes operation request_json a versioned, frozen execution snapshot.
238 // The shape is validated by the application because keeping the JSON opaque
239 // lets older completed records remain readable without rewriting user data.
240 func migrateV2ToV3(db *sql.DB) error {
241 tx, err := db.Begin()
242 if err != nil {
243 return err
244 }
245 defer rollbackTransaction(tx)
246 if _, err := tx.Exec(`PRAGMA user_version = 3`); err != nil {
247 return err
248 }
249 return tx.Commit()
250 }
251
252 func draftFileDSN(path string) string {
253 abs, err := filepath.Abs(path)
254 if err != nil {
255 abs = path
256 }
257 slash := filepath.ToSlash(abs)
258 if runtime.GOOS == "windows" && len(slash) >= 2 && slash[1] == ':' {
259 slash = "/" + slash
260 }
261 u := &url.URL{Scheme: "file", Path: slash}
262 return u.String() + "?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29"
263 }
264
265 func (s *Store) withDB(ctx context.Context, fn func(*sql.DB) error) error {
266 s.mu.Lock()
267 defer s.mu.Unlock()
268 deadline := time.Now().Add(5 * time.Second)
269 backoff := 5 * time.Millisecond
270 for {
271 if err := s.openLocked(); err != nil {
272 if !isSQLiteBusy(err) {
273 return err
274 }
275 } else if err := fn(s.db); err != nil {
276 if !isSQLiteBusy(err) {
277 return err
278 }
279 } else {
280 return nil
281 }
282
283 if time.Now().After(deadline) {
284 return fmt.Errorf("session draft database remained busy for 5s")
285 }
286 timer := time.NewTimer(backoff)
287 select {
288 case <-ctx.Done():
289 timer.Stop()
290 return ctx.Err()
291 case <-timer.C:
292 }
293 if backoff < 100*time.Millisecond {
294 backoff *= 2
295 }
296 }
297 }
298
299 func isSQLiteBusy(err error) bool {
300 var sqliteErr *sqlite.Error
301 if !errors.As(err, &sqliteErr) {
302 return false
303 }
304 code := sqliteErr.Code() & 0xff
305 return code == 5 || code == 6
306 }
307
308 func scanDraft(row interface{ Scan(...any) error }) (Draft, error) {
309 var draft Draft
310 var updated int64
311 err := row.Scan(&draft.ID, &draft.WorkspaceID, &draft.Scope, &draft.WorkspaceRoot,
312 &draft.Revision, &draft.ContentJSON, &draft.SettingsJSON, &draft.Status, &updated)
313 if errors.Is(err, sql.ErrNoRows) {
314 return Draft{}, ErrNotFound
315 }
316 draft.UpdatedAt = time.UnixMilli(updated).UTC()
317 return draft, err
318 }
319
320 const draftColumns = `id, workspace_id, scope, workspace_root, revision, content_json, settings_json, status, updated_at`
321
322 func (s *Store) Open(ctx context.Context, workspaceID, scope, root, draftID, settings string) (Draft, bool, error) {
323 var result Draft
324 created := false
325 err := s.withDB(ctx, func(db *sql.DB) error {
326 current, err := scanDraft(db.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE workspace_id = ? AND status = 'active'`, workspaceID))
327 if err == nil {
328 result = current
329 return nil
330 }
331 if !errors.Is(err, ErrNotFound) {
332 return err
333 }
334 now := time.Now().UTC().UnixMilli()
335 _, err = db.ExecContext(ctx, `INSERT INTO drafts(id, workspace_id, scope, workspace_root, revision, content_json, settings_json, status, updated_at) VALUES(?,?,?,?,1,'{}',?,'active',?)`, draftID, workspaceID, scope, root, settings, now)
336 if err != nil {
337 // A concurrent process may have won the unique active-draft insert.
338 current, lookupErr := scanDraft(db.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE workspace_id = ? AND status = 'active'`, workspaceID))
339 if lookupErr != nil {
340 return err
341 }
342 result = current
343 return nil
344 }
345 result = Draft{ID: draftID, WorkspaceID: workspaceID, Scope: scope, WorkspaceRoot: root, Revision: 1, ContentJSON: "{}", SettingsJSON: settings, Status: "active", UpdatedAt: time.UnixMilli(now).UTC()}
346 created = true
347 return nil
348 })
349 return result, created, err
350 }
351
352 func (s *Store) Get(ctx context.Context, draftID string) (Draft, error) {
353 var result Draft
354 err := s.withDB(ctx, func(db *sql.DB) error {
355 var err error
356 result, err = scanDraft(db.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE id = ?`, draftID))
357 return err
358 })
359 return result, err
360 }
361
362 func (s *Store) SetRestore(ctx context.Context, draftID string) error {
363 return s.withDB(ctx, func(db *sql.DB) error {
364 if strings.TrimSpace(draftID) == "" {
365 _, err := db.ExecContext(ctx, `DELETE FROM restore_state WHERE slot='active'`)
366 return err
367 }
368 var status string
369 if err := db.QueryRowContext(ctx, `SELECT status FROM drafts WHERE id=?`, draftID).Scan(&status); errors.Is(err, sql.ErrNoRows) {
370 return ErrNotFound
371 } else if err != nil {
372 return err
373 } else if status != "active" {
374 return ErrConverted
375 }
376 _, err := db.ExecContext(ctx, `INSERT INTO restore_state(slot,draft_id,updated_at) VALUES('active',?,?) ON CONFLICT(slot) DO UPDATE SET draft_id=excluded.draft_id, updated_at=excluded.updated_at`, draftID, time.Now().UTC().UnixMilli())
377 return err
378 })
379 }
380
381 func (s *Store) Restore(ctx context.Context) (Draft, error) {
382 var result Draft
383 err := s.withDB(ctx, func(db *sql.DB) error {
384 var err error
385 result, err = scanDraft(db.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE id=(SELECT draft_id FROM restore_state WHERE slot='active') AND status='active'`))
386 return err
387 })
388 return result, err
389 }
390
391 func (s *Store) ClearRestore(ctx context.Context, draftID string) error {
392 return s.withDB(ctx, func(db *sql.DB) error {
393 _, err := db.ExecContext(ctx, `DELETE FROM restore_state WHERE slot='active' AND draft_id=?`, draftID)
394 return err
395 })
396 }
397
398 func (s *Store) Save(ctx context.Context, draftID string, expected uint64, content, settings string, _ bool) (Draft, error) {
399 var result Draft
400 err := s.withDB(ctx, func(db *sql.DB) error {
401 tx, err := db.BeginTx(ctx, nil)
402 if err != nil {
403 return err
404 }
405 defer rollbackTransaction(tx)
406 current, err := scanDraft(tx.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE id = ?`, draftID))
407 if err != nil {
408 return err
409 }
410 if current.Status != "active" {
411 return ErrConverted
412 }
413 var activeOperations int
414 if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM operations WHERE draft_id=? AND phase NOT IN ('cancelled','terminal_failed')`, draftID).Scan(&activeOperations); err != nil {
415 return err
416 }
417 if activeOperations > 0 {
418 return ErrOperationConflict
419 }
420 if current.Revision != expected {
421 _, conflictErr := tx.ExecContext(ctx, `INSERT INTO conflicts(draft_id,expected_revision,actual_revision,content_json,settings_json,created_at) VALUES(?,?,?,?,?,?)`, draftID, expected, current.Revision, content, settings, time.Now().UTC().UnixMilli())
422 if conflictErr != nil {
423 return conflictErr
424 }
425 if err := tx.Commit(); err != nil {
426 return err
427 }
428 result = current
429 return ErrConflict
430 }
431 now := time.Now().UTC().UnixMilli()
432 next := current.Revision + 1
433 resultUpdate, err := tx.ExecContext(ctx, `UPDATE drafts SET revision=?,content_json=?,settings_json=?,updated_at=? WHERE id=? AND status='active' AND revision=?`, next, content, settings, now, draftID, current.Revision)
434 if err != nil {
435 return err
436 }
437 if changed, err := resultUpdate.RowsAffected(); err != nil || changed != 1 {
438 if err != nil {
439 return err
440 }
441 return ErrConflict
442 }
443 if err := tx.Commit(); err != nil {
444 return err
445 }
446 result = current
447 result.Revision = next
448 result.ContentJSON = content
449 result.SettingsJSON = settings
450 result.UpdatedAt = time.UnixMilli(now).UTC()
451 return nil
452 })
453 return result, err
454 }
455
456 func (s *Store) ListActive(ctx context.Context) ([]Draft, error) {
457 out := []Draft{}
458 err := s.withDB(ctx, func(db *sql.DB) error {
459 rows, err := db.QueryContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE status='active' ORDER BY updated_at DESC`)
460 if err != nil {
461 return err
462 }
463 defer rows.Close()
464 for rows.Next() {
465 draft, err := scanDraft(rows)
466 if err != nil {
467 return err
468 }
469 out = append(out, draft)
470 }
471 return rows.Err()
472 })
473 return out, err
474 }
475
476 func (s *Store) Discard(ctx context.Context, draftID string, expected uint64) error {
477 return s.withDB(ctx, func(db *sql.DB) error {
478 tx, err := db.BeginTx(ctx, nil)
479 if err != nil {
480 return err
481 }
482 defer rollbackTransaction(tx)
483 current, err := scanDraft(tx.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE id=?`, draftID))
484 if err != nil {
485 return err
486 }
487 if current.Status != "active" {
488 return ErrConverted
489 }
490 if current.Revision != expected {
491 return ErrConflict
492 }
493 var count int
494 if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM operations WHERE draft_id=? AND phase NOT IN ('cancelled','terminal_failed')`, draftID).Scan(&count); err != nil {
495 return err
496 }
497 if count > 0 {
498 return ErrOperationConflict
499 }
500 if _, err = tx.ExecContext(ctx, `UPDATE drafts SET status='discarded',revision=revision+1,updated_at=? WHERE id=?`, time.Now().UTC().UnixMilli(), draftID); err != nil {
501 return err
502 }
503 if _, err = tx.ExecContext(ctx, `DELETE FROM restore_state WHERE slot='active' AND draft_id=?`, draftID); err != nil {
504 return err
505 }
506 return tx.Commit()
507 })
508 }
509
510 func (s *Store) BeginOperation(ctx context.Context, op Operation) (Operation, bool, error) {
511 var result Operation
512 created := false
513 err := s.withDB(ctx, func(db *sql.DB) error {
514 tx, err := db.BeginTx(ctx, nil)
515 if err != nil {
516 return err
517 }
518 defer rollbackTransaction(tx)
519 // Resolve request identity before checking the editable slot: a lost
520 // response may be retried after receipt has converted the draft.
521 if op.RequestID != "" {
522 prior, lookupErr := scanOperation(tx.QueryRowContext(ctx, `SELECT `+operationColumns+` FROM operations WHERE draft_id=? AND request_id=?`, op.DraftID, op.RequestID))
523 if lookupErr == nil {
524 if prior.Fingerprint != op.Fingerprint || prior.SourceDigest != op.SourceDigest {
525 return ErrOperationConflict
526 }
527 result = prior
528 return nil
529 }
530 if !errors.Is(lookupErr, ErrOperationNotFound) {
531 return lookupErr
532 }
533 }
534 draft, err := scanDraft(tx.QueryRowContext(ctx, `SELECT `+draftColumns+` FROM drafts WHERE id=?`, op.DraftID))
535 if err != nil {
536 return err
537 }
538 if draft.Status != "active" {
539 return ErrConverted
540 }
541 if draft.Revision != op.DraftRevision {
542 return ErrConflict
543 }
544 if op.SourceDigest != "" {
545 digest, err := SnapshotDigest(draft.ContentJSON, draft.SettingsJSON)
546 if err != nil {
547 return err
548 }
549 if digest != op.SourceDigest {
550 return ErrConflict
551 }
552 }
553 prior, err := scanOperation(tx.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE draft_id=? ORDER BY rowid DESC LIMIT 1`, op.DraftID))
554 if err == nil && prior.Phase != "cancelled" && prior.Phase != "terminal_failed" {
555 if prior.Fingerprint != op.Fingerprint {
556 return ErrOperationConflict
557 }
558 result = prior
559 return nil
560 }
561 if err != nil && !errors.Is(err, ErrOperationNotFound) {
562 return err
563 }
564 if err == nil && prior.SessionID != "" {
565 op.SessionID = prior.SessionID
566 op.TopicID = prior.TopicID
567 }
568 now := time.Now().UTC()
569 op.CreatedAt, op.UpdatedAt, op.Phase, op.Error, op.Revision = now, now, "reserved", "", 1
570 _, err = tx.ExecContext(ctx, `INSERT INTO operations(id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, op.ID, op.DraftID, op.WorkspaceID, op.DraftRevision, op.SessionID, op.TopicID, op.SubmissionID, op.Fingerprint, op.RequestJSON, op.Phase, op.Error, now.UnixMilli(), now.UnixMilli(), op.RequestID, op.SourceDigest, op.Revision)
571 if err != nil {
572 return err
573 }
574 if err := tx.Commit(); err != nil {
575 return err
576 }
577 result, created = op, true
578 return nil
579 })
580 return result, created, err
581 }
582
583 func scanOperation(row interface{ Scan(...any) error }) (Operation, error) {
584 var op Operation
585 var created, updated int64
586 err := row.Scan(&op.ID, &op.DraftID, &op.WorkspaceID, &op.DraftRevision, &op.SessionID, &op.TopicID, &op.SubmissionID, &op.Fingerprint, &op.RequestJSON, &op.Phase, &op.Error, &created, &updated, &op.RequestID, &op.SourceDigest, &op.Revision, &op.ExecutionJSON)
587 if errors.Is(err, sql.ErrNoRows) {
588 return Operation{}, ErrOperationNotFound
589 }
590 op.CreatedAt, op.UpdatedAt = time.UnixMilli(created).UTC(), time.UnixMilli(updated).UTC()
591 return op, err
592 }
593
594 func (s *Store) Operation(ctx context.Context, id string) (Operation, error) {
595 var result Operation
596 err := s.withDB(ctx, func(db *sql.DB) error {
597 var err error
598 result, err = scanOperation(db.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE id=?`, id))
599 return err
600 })
601 return result, err
602 }
603
604 func (s *Store) PendingOperations(ctx context.Context) ([]Operation, error) {
605 out := []Operation{}
606 err := s.withDB(ctx, func(db *sql.DB) error {
607 rows, err := db.QueryContext(ctx, `SELECT o.id,o.draft_id,o.workspace_id,o.draft_revision,o.session_id,o.topic_id,o.submission_id,o.fingerprint,o.request_json,o.phase,o.error,o.created_at,o.updated_at,o.request_id,o.source_digest,o.operation_revision,o.execution_json FROM operations o JOIN drafts d ON d.id=o.draft_id WHERE d.status='active' AND o.phase NOT IN ('cancelled','terminal_failed') ORDER BY o.created_at`)
608 if err != nil {
609 return err
610 }
611 defer rows.Close()
612 for rows.Next() {
613 op, err := scanOperation(rows)
614 if err != nil {
615 return err
616 }
617 out = append(out, op)
618 }
619 return rows.Err()
620 })
621 return out, err
622 }
623
624 func (s *Store) SetOperationPhase(ctx context.Context, id, phase, message string) (Operation, error) {
625 var result Operation
626 err := s.withDB(ctx, func(db *sql.DB) error {
627 now := time.Now().UTC().UnixMilli()
628 if _, err := db.ExecContext(ctx, `UPDATE operations SET phase=?,error=?,updated_at=?,operation_revision=operation_revision+1 WHERE id=?`, phase, message, now, id); err != nil {
629 return err
630 }
631 var err error
632 result, err = scanOperation(db.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE id=?`, id))
633 return err
634 })
635 return result, err
636 }
637
638 func (s *Store) ClaimOperationPhase(ctx context.Context, id string, from []string, phase string) (Operation, bool, error) {
639 return s.TransitionOperationPhase(ctx, id, from, phase, "")
640 }
641
642 func (s *Store) TransitionOperationPhase(ctx context.Context, id string, from []string, phase, message string) (Operation, bool, error) {
643 var result Operation
644 claimed := false
645 err := s.withDB(ctx, func(db *sql.DB) error {
646 if len(from) == 0 {
647 return ErrOperationConflict
648 }
649 placeholders := make([]string, len(from))
650 args := make([]any, 0, len(from)+4)
651 args = append(args, phase, message, time.Now().UTC().UnixMilli(), id)
652 for i, value := range from {
653 placeholders[i] = "?"
654 args = append(args, value)
655 }
656 updated, err := db.ExecContext(ctx, `UPDATE operations SET phase=?,error=?,updated_at=?,operation_revision=operation_revision+1 WHERE id=? AND phase IN (`+strings.Join(placeholders, ",")+`)`, args...)
657 if err != nil {
658 return err
659 }
660 count, err := updated.RowsAffected()
661 if err != nil {
662 return err
663 }
664 claimed = count == 1
665 result, err = scanOperation(db.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE id=?`, id))
666 return err
667 })
668 return result, claimed, err
669 }
670
671 func (s *Store) UpdateOperationRequest(ctx context.Context, id, phase, requestJSON string) (Operation, bool, error) {
672 var result Operation
673 updated := false
674 err := s.withDB(ctx, func(db *sql.DB) error {
675 now := time.Now().UTC().UnixMilli()
676 change, err := db.ExecContext(ctx, `UPDATE operations SET execution_json=?,updated_at=?,operation_revision=operation_revision+1 WHERE id=? AND phase=?`, requestJSON, now, id, phase)
677 if err != nil {
678 return err
679 }
680 count, err := change.RowsAffected()
681 if err != nil {
682 return err
683 }
684 updated = count == 1
685 result, err = scanOperation(db.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE id=?`, id))
686 return err
687 })
688 return result, updated, err
689 }
690
691 func (s *Store) EnsureOperationTopic(ctx context.Context, id, topicID string) (Operation, error) {
692 var result Operation
693 err := s.withDB(ctx, func(db *sql.DB) error {
694 if _, err := db.ExecContext(ctx, `UPDATE operations SET topic_id=?,updated_at=?,operation_revision=operation_revision+1 WHERE id=? AND topic_id=''`, topicID, time.Now().UTC().UnixMilli(), id); err != nil {
695 return err
696 }
697 var err error
698 result, err = scanOperation(db.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE id=?`, id))
699 return err
700 })
701 return result, err
702 }
703
704 // AcceptAndConvert publishes the durable submission receipt and releases the
705 // Workspace draft slot in one database transaction.
706 func (s *Store) AcceptAndConvert(ctx context.Context, draftID, operationID string) (Operation, error) {
707 var result Operation
708 err := s.withDB(ctx, func(db *sql.DB) error {
709 tx, err := db.BeginTx(ctx, nil)
710 if err != nil {
711 return err
712 }
713 defer rollbackTransaction(tx)
714 op, err := scanOperation(tx.QueryRowContext(ctx, `SELECT id,draft_id,workspace_id,draft_revision,session_id,topic_id,submission_id,fingerprint,request_json,phase,error,created_at,updated_at,request_id,source_digest,operation_revision,execution_json FROM operations WHERE id=? AND draft_id=?`, operationID, draftID))
715 if err != nil {
716 return err
717 }
718 if op.Phase != "accepted" && op.Phase != "dispatching" && op.Phase != "dispatch_unknown" && op.Phase != "dispatching_shell" {
719 return ErrOperationConflict
720 }
721 now := time.Now().UTC().UnixMilli()
722 if op.Phase != "accepted" {
723 changed, err := tx.ExecContext(ctx, `UPDATE operations SET phase='accepted',error='',updated_at=?,operation_revision=operation_revision+1 WHERE id=? AND phase=?`, now, operationID, op.Phase)
724 if err != nil {
725 return err
726 }
727 op.Revision++
728 if count, err := changed.RowsAffected(); err != nil || count != 1 {
729 if err != nil {
730 return err
731 }
732 return ErrOperationConflict
733 }
734 }
735 converted, err := tx.ExecContext(ctx, `UPDATE drafts SET status='converted',revision=revision+1,updated_at=? WHERE id=? AND status='active'`, now, draftID)
736 if err != nil {
737 return err
738 }
739 if count, err := converted.RowsAffected(); err != nil {
740 return err
741 } else if count == 0 {
742 var status string
743 if err := tx.QueryRowContext(ctx, `SELECT status FROM drafts WHERE id=?`, draftID).Scan(&status); err != nil {
744 return err
745 }
746 if status != "converted" {
747 return ErrOperationConflict
748 }
749 }
750 if _, err := tx.ExecContext(ctx, `DELETE FROM restore_state WHERE slot='active' AND draft_id=?`, draftID); err != nil {
751 return err
752 }
753 if err := tx.Commit(); err != nil {
754 return err
755 }
756 op.Phase, op.Error, op.UpdatedAt = "accepted", "", time.UnixMilli(now).UTC()
757 result = op
758 return nil
759 })
760 return result, err
761 }
762
763 func (s *Store) Convert(ctx context.Context, draftID, operationID string) error {
764 return s.withDB(ctx, func(db *sql.DB) error {
765 tx, err := db.BeginTx(ctx, nil)
766 if err != nil {
767 return err
768 }
769 defer rollbackTransaction(tx)
770 var phase string
771 if err := tx.QueryRowContext(ctx, `SELECT phase FROM operations WHERE id=? AND draft_id=?`, operationID, draftID).Scan(&phase); err != nil {
772 return err
773 }
774 if phase != "accepted" {
775 return ErrOperationConflict
776 }
777 if _, err := tx.ExecContext(ctx, `UPDATE drafts SET status='converted',revision=revision+1,updated_at=? WHERE id=? AND status='active'`, time.Now().UTC().UnixMilli(), draftID); err != nil {
778 return err
779 }
780 return tx.Commit()
781 })
782 }
783
783 lines GO