relay-flow 0.2.4-alpha → 0.2.5-alpha

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 (45) hide show
  1. package/README.md +20 -15
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +94 -13
  4. package/cmd/relay-flow/scenario_test.go +19 -2
  5. package/cmd/relay-flow/serve.go +98 -19
  6. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  7. package/cmd/relay-flow/temporal_init.go +170 -0
  8. package/cmd/relay-flow/temporal_init_test.go +217 -0
  9. package/cmd/relay-flow/temporal_report_test.go +733 -0
  10. package/examples/config-reference.yaml +2 -2
  11. package/examples/minimal-beads-task-workflow.yaml +2 -1
  12. package/examples/workflow-reference.yaml +2 -1
  13. package/go.mod +37 -16
  14. package/go.sum +129 -61
  15. package/internal/config/machine.go +33 -1
  16. package/internal/config/machine_test.go +76 -0
  17. package/internal/execution/goworkflows/engine.go +13 -38
  18. package/internal/execution/goworkflows/projection.go +47 -464
  19. package/internal/execution/projection/projection.go +867 -0
  20. package/internal/execution/projection/projection_test.go +347 -0
  21. package/internal/execution/temporal/activities.go +567 -0
  22. package/internal/execution/temporal/engine.go +384 -0
  23. package/internal/execution/temporal/engine_test.go +277 -0
  24. package/internal/execution/temporal/interpreter.go +736 -0
  25. package/internal/execution/temporal/operations.go +455 -0
  26. package/internal/execution/temporal/operations_test.go +101 -0
  27. package/internal/execution/temporal/recovery.go +194 -0
  28. package/internal/execution/temporal/recovery_runtime.go +41 -0
  29. package/internal/execution/temporal/recovery_test.go +102 -0
  30. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  31. package/internal/execution/temporal/spike_test.go +934 -0
  32. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  33. package/internal/harness/opencode/opencode.go +3 -1
  34. package/internal/harness/opencode/opencode_test.go +1 -1
  35. package/internal/harness/opencode/repo_setup.go +1 -1
  36. package/internal/harness/pi/pi.go +49 -46
  37. package/internal/harness/pi/pi_test.go +26 -10
  38. package/internal/harness/pi/prompt_test.go +30 -1
  39. package/internal/harness/pi/validation_test.go +27 -51
  40. package/internal/runner/herdr/herdr.go +14 -0
  41. package/internal/runner/herdr/herdr_test.go +20 -0
  42. package/internal/runner/orca/orca.go +33 -0
  43. package/internal/runner/orca/orca_test.go +33 -4
  44. package/internal/runner/runner.go +8 -0
  45. package/package.json +1 -1
@@ -23,6 +23,7 @@ import (
23
23
  goworkflow "github.com/cschleiden/go-workflows/workflow"
24
24
  "github.com/google/uuid"
25
25
 
26
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
26
27
  "github.com/rajpopat27/relay-flow/internal/harness"
27
28
  "github.com/rajpopat27/relay-flow/internal/identity"
28
29
  "github.com/rajpopat27/relay-flow/internal/repo"
@@ -68,46 +69,13 @@ type Engine struct {
68
69
  workerName string
69
70
  }
70
71
 
71
- // InitDatabase creates the SQLite database at path (mode 0600) with the
72
- // relay_runs projection schema and closes it. Used by `relay-flow init`;
73
- // serve uses New to open the full engine.
74
- func InitDatabase(path string) error {
75
- db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
76
- if err != nil {
77
- return fmt.Errorf("open %s: %w", path, err)
78
- }
79
- defer db.Close()
80
- if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
81
- return fmt.Errorf("open %s: %w", path, err)
82
- }
83
- if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
84
- return fmt.Errorf("open %s: %w", path, err)
85
- }
86
- if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
87
- return fmt.Errorf("chmod %s: %w", path, err)
88
- }
89
- proj := &RunProjection{DB: db}
90
- if err := proj.migrate(); err != nil {
91
- return fmt.Errorf("migrate relay_runs: %w", err)
92
- }
93
- return nil
94
- }
72
+ // InitDatabase preserves the embedded-engine public helper while delegating
73
+ // relay-owned schema lifecycle to the shared projection package.
74
+ func InitDatabase(path string) error { return projection.InitDatabase(path) }
95
75
 
96
- // HasNonterminalRuns inspects an existing database without migrating or
97
- // otherwise modifying it. It is used by init --force before config changes.
76
+ // HasNonterminalRuns inspects the shared projection without migrating it.
98
77
  func HasNonterminalRuns(path string) (bool, error) {
99
- db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
100
- if err != nil {
101
- return false, fmt.Errorf("open %s: %w", path, err)
102
- }
103
- defer db.Close()
104
- var active bool
105
- if err := db.QueryRow(`SELECT EXISTS(
106
- SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
107
- )`).Scan(&active); err != nil {
108
- return false, fmt.Errorf("inspect %s: %w", path, err)
109
- }
110
- return active, nil
78
+ return projection.HasNonterminalRuns(path)
111
79
  }
112
80
 
113
81
  // New opens the SQLite database at path (created with mode 0600 when
@@ -148,6 +116,13 @@ func New(path string, deps Dependencies) (*Engine, error) {
148
116
  db.Close()
149
117
  return nil, fmt.Errorf("migrate relay_runs: %w", err)
150
118
  }
119
+ // A marker-less legacy database is adopted only by the embedded executor;
120
+ // a persisted Temporal identity fails closed rather than being combined
121
+ // with go-workflows state.
122
+ if err := (&projection.RunProjection{DB: db}).VerifyIdentity(context.Background(), projection.ExecutorIdentity{ExecutorPlugin: "goworkflows"}); err != nil {
123
+ db.Close()
124
+ return nil, fmt.Errorf("verify executor identity: %w", err)
125
+ }
151
126
  activities := &Activities{
152
127
  Repos: deps.Repos,
153
128
  Runner: deps.Runner,
@@ -3,543 +3,126 @@ package goworkflows
3
3
  import (
4
4
  "context"
5
5
  "database/sql"
6
- "errors"
7
- "fmt"
8
6
  "sync"
9
7
  "time"
10
8
 
9
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
11
10
  "github.com/rajpopat27/relay-flow/internal/run"
12
11
  )
13
12
 
14
- // RunProjection is the derived relay_runs read model in the same SQLite
15
- // database as the engine backend. Durable workflow history is authoritative;
16
- // this table serves application-level queries only. Updates are idempotent
17
- // durable activities, so replay repairs interrupted updates.
13
+ // RunProjection is retained as the embedded-engine facade for compatibility
14
+ // with the existing go-workflows activities and tests. The relay schema and
15
+ // SQL implementation live in internal/execution/projection so Temporal and
16
+ // goworkflows share exactly one projection implementation.
18
17
  type RunProjection struct {
19
18
  DB *sql.DB
19
+ inner *projection.RunProjection
20
20
  runtimeMu sync.Mutex
21
21
  }
22
22
 
23
- // NodeRuntime is the durable runtime identity for one node in a run. Unlike
24
- // relay_runs' current-node fields, one row is retained for every visited node.
25
- type NodeRuntime struct {
26
- RunID run.ID
27
- Node string
28
- TerminalID string
29
- SessionID string
30
- NodeVisitID run.NodeVisitID
31
- UpdatedAt time.Time
32
- }
23
+ // NodeRuntime is the durable runtime identity for one node in a run.
24
+ type NodeRuntime = projection.NodeRuntime
33
25
 
34
- const relayRunsSchema = `
35
- CREATE TABLE IF NOT EXISTS relay_runs (
36
- id TEXT PRIMARY KEY,
37
- logical_run_id TEXT,
38
- attempt_id INTEGER,
39
- repo TEXT NOT NULL,
40
- workflow TEXT NOT NULL,
41
- ticket_id TEXT NOT NULL,
42
- ticket_key TEXT NOT NULL,
43
- state TEXT NOT NULL,
44
- current_node TEXT,
45
- current_node_visit_id TEXT,
46
- last_error TEXT,
47
- retry_error TEXT,
48
- retry_attempt INTEGER,
49
- next_retry_at DATETIME,
50
- started_at DATETIME NOT NULL,
51
- updated_at DATETIME NOT NULL,
52
- finished_at DATETIME
53
- );
54
- CREATE INDEX IF NOT EXISTS relay_runs_ticket_key ON relay_runs (ticket_key);
55
- CREATE INDEX IF NOT EXISTS relay_runs_workflow_state ON relay_runs (workflow, state);
56
- CREATE INDEX IF NOT EXISTS relay_runs_repo_state ON relay_runs (repo, state);
57
- CREATE TABLE IF NOT EXISTS relay_node_runtime (
58
- run_id TEXT NOT NULL,
59
- node TEXT NOT NULL,
60
- terminal_id TEXT,
61
- session_id TEXT,
62
- node_visit_id TEXT NOT NULL,
63
- updated_at DATETIME NOT NULL,
64
- PRIMARY KEY (run_id, node),
65
- FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
66
- );
67
- CREATE TABLE IF NOT EXISTS relay_processed_reports (
68
- run_id TEXT NOT NULL,
69
- report_id TEXT NOT NULL,
70
- node_visit_id TEXT NOT NULL,
71
- created_at DATETIME NOT NULL,
72
- PRIMARY KEY (run_id, report_id),
73
- FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
74
- );
75
- CREATE TABLE IF NOT EXISTS relay_node_sessions (
76
- run_id TEXT NOT NULL,
77
- node TEXT NOT NULL,
78
- session_id TEXT NOT NULL,
79
- node_visit_id TEXT NOT NULL,
80
- created_at DATETIME NOT NULL,
81
- PRIMARY KEY (run_id, node, session_id),
82
- FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
83
- );
84
- `
26
+ var errRunNotFound = projection.ErrRunNotFound
27
+ var errNodeRuntimeNotFound = projection.ErrNodeRuntimeNotFound
85
28
 
86
- func (p *RunProjection) migrate() error {
87
- if _, err := p.DB.Exec(relayRunsSchema); err != nil {
88
- return err
89
- }
90
- for name, definition := range map[string]string{
91
- "logical_run_id": "TEXT",
92
- "attempt_id": "INTEGER",
93
- "retry_error": "TEXT",
94
- "retry_attempt": "INTEGER",
95
- "next_retry_at": "DATETIME",
96
- } {
97
- var count int
98
- if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
99
- return err
100
- }
101
- if count == 0 {
102
- if _, err := p.DB.Exec(`ALTER TABLE relay_runs ADD COLUMN ` + name + ` ` + definition); err != nil {
103
- return err
104
- }
105
- }
106
- }
107
- // Rows created before attempt identities were introduced represent the
108
- // original attempt. Backfill the stable logical ID and attempt number so
109
- // restart allocation remains numeric and never reuses attempt 1.
110
- if _, err := p.DB.Exec(`UPDATE relay_runs SET logical_run_id = id WHERE COALESCE(logical_run_id, '') = ''`); err != nil {
111
- return err
112
- }
113
- if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
114
- return err
29
+ // IsNotFound reports a missing projection row.
30
+ func IsNotFound(err error) bool { return projection.IsNotFound(err) }
31
+
32
+ func (p *RunProjection) shared() *projection.RunProjection {
33
+ if p.inner == nil {
34
+ p.inner = &projection.RunProjection{DB: p.DB}
115
35
  }
116
- return nil
36
+ return p.inner
117
37
  }
118
38
 
119
- var errRunNotFound = errors.New("run not found")
120
- var errNodeRuntimeNotFound = errors.New("node runtime not found")
39
+ func (p *RunProjection) migrate() error { return p.shared().Migrate() }
121
40
 
122
- // IsNotFound reports a missing projection row.
123
- func IsNotFound(err error) bool { return errors.Is(err, errRunNotFound) }
124
-
125
- func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.Time) error {
126
- logicalID := s.LogicalID
127
- if logicalID == "" {
128
- logicalID = s.ID
129
- }
130
- attemptID := s.AttemptID
131
- if attemptID == 0 {
132
- attemptID = 1
133
- }
134
- _, err := p.DB.ExecContext(ctx, `
135
- INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
136
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
137
- ON CONFLICT(id) DO NOTHING`,
138
- string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
139
- string(run.StateStarting), now, now)
140
- return err
41
+ func (p *RunProjection) insertStart(ctx context.Context, start run.Start, now time.Time) error {
42
+ return p.shared().InsertStart(ctx, start, now)
141
43
  }
142
44
 
143
45
  func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
144
- terminal := state == run.StateCompleted || state == run.StateCanceled
145
- _, err := p.DB.ExecContext(ctx, `
146
- UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
147
- retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
148
- retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
149
- next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
150
- WHERE id = ?`,
151
- string(state), lastErr, time.Now().UTC(), finished, terminal, terminal, terminal, string(id))
152
- return err
46
+ return p.shared().UpdateState(ctx, id, state, lastErr, finished)
153
47
  }
154
48
 
155
49
  func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
156
- if status == nil {
157
- _, err := p.DB.ExecContext(ctx, `
158
- UPDATE relay_runs SET retry_error = NULL, retry_attempt = NULL, next_retry_at = NULL, updated_at = ?
159
- WHERE id = ?`, time.Now().UTC(), string(id))
160
- return err
161
- }
162
- _, err := p.DB.ExecContext(ctx, `
163
- UPDATE relay_runs SET retry_error = ?, retry_attempt = ?, next_retry_at = ?, updated_at = ?
164
- WHERE id = ?`, status.LastError, status.Attempt, status.NextRetryAt, time.Now().UTC(), string(id))
165
- return err
50
+ return p.shared().UpdateRetry(ctx, id, status)
166
51
  }
167
52
 
168
53
  func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
169
- p.runtimeMu.Lock()
170
- defer p.runtimeMu.Unlock()
171
- tx, err := p.DB.BeginTx(ctx, nil)
172
- if err != nil {
173
- return err
174
- }
175
- defer tx.Rollback()
176
- now := time.Now().UTC()
177
- if _, err := tx.ExecContext(ctx, `
178
- UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
179
- WHERE id = ?`,
180
- string(state), node, string(visit), now, string(id)); err != nil {
181
- return err
182
- }
183
- // A revisit changes only the latest visit ID. Reusable terminal/session
184
- // identities remain attached to this run/node row.
185
- if _, err := tx.ExecContext(ctx, `
186
- INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
187
- VALUES (?, ?, ?, ?)
188
- ON CONFLICT(run_id, node) DO UPDATE SET
189
- node_visit_id = excluded.node_visit_id,
190
- updated_at = excluded.updated_at`,
191
- string(id), node, string(visit), now); err != nil {
192
- return err
193
- }
194
- return tx.Commit()
54
+ return p.shared().UpdateNode(ctx, id, state, node, visit)
195
55
  }
196
56
 
197
57
  func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
198
- var rt NodeRuntime
199
- var terminalID, sessionID sql.NullString
200
- err := p.DB.QueryRowContext(ctx, `
201
- SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
202
- FROM relay_node_runtime WHERE run_id = ? AND node = ?`, string(id), node).
203
- Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt)
204
- if errors.Is(err, sql.ErrNoRows) {
205
- return NodeRuntime{}, errNodeRuntimeNotFound
206
- }
207
- if err != nil {
208
- return NodeRuntime{}, err
209
- }
210
- rt.TerminalID = terminalID.String
211
- rt.SessionID = sessionID.String
212
- return rt, nil
58
+ return p.shared().GetNodeRuntime(ctx, id, node)
213
59
  }
214
60
 
215
- func (p *RunProjection) updateNodeRuntime(ctx context.Context, rt NodeRuntime) error {
216
- _, err := p.DB.ExecContext(ctx, `
217
- INSERT INTO relay_node_runtime (run_id, node, terminal_id, session_id, node_visit_id, updated_at)
218
- VALUES (?, ?, ?, ?, ?, ?)
219
- ON CONFLICT(run_id, node) DO UPDATE SET
220
- terminal_id = excluded.terminal_id,
221
- session_id = excluded.session_id,
222
- node_visit_id = excluded.node_visit_id,
223
- updated_at = excluded.updated_at`,
224
- string(rt.RunID), rt.Node, nullableString(rt.TerminalID), nullableString(rt.SessionID),
225
- string(rt.NodeVisitID), time.Now().UTC())
226
- return err
61
+ func (p *RunProjection) updateNodeRuntime(ctx context.Context, runtime NodeRuntime) error {
62
+ return p.shared().UpdateNodeRuntime(ctx, runtime)
227
63
  }
228
64
 
229
65
  func (p *RunProjection) loadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
230
- rt, err := p.getNodeRuntime(ctx, id, node)
231
- if errors.Is(err, errNodeRuntimeNotFound) {
232
- return NodeRuntime{RunID: id, Node: node}, nil
233
- }
234
- return rt, err
66
+ return p.shared().LoadNodeRuntime(ctx, id, node)
235
67
  }
236
68
 
237
69
  func (p *RunProjection) nodeRuntimeVisitIsCurrent(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) (bool, error) {
238
- var count int
239
- err := p.DB.QueryRowContext(ctx, `
240
- SELECT COUNT(1) FROM relay_node_runtime
241
- WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
242
- string(id), node, string(visit)).Scan(&count)
243
- return count == 1, err
70
+ return p.shared().NodeRuntimeVisitIsCurrent(ctx, id, node, visit)
244
71
  }
245
72
 
246
73
  func (p *RunProjection) replaceNodeRuntime(ctx context.Context, id run.ID, node string, visit run.NodeVisitID, terminalID, previousSessionID, sessionID string) error {
247
- result, err := p.DB.ExecContext(ctx, `
248
- UPDATE relay_node_runtime SET terminal_id = ?,
249
- session_id = CASE WHEN COALESCE(session_id, '') = ? THEN ? ELSE session_id END,
250
- updated_at = ?
251
- WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
252
- nullableString(terminalID), previousSessionID, nullableString(sessionID), time.Now().UTC(),
253
- string(id), node, string(visit))
254
- if err != nil {
255
- return err
256
- }
257
- updated, err := result.RowsAffected()
258
- if err != nil {
259
- return err
260
- }
261
- if updated != 1 {
262
- return fmt.Errorf("node runtime %s/%s visit %s is not current", id, node, visit)
263
- }
264
- return nil
74
+ return p.shared().ReplaceNodeRuntime(ctx, id, node, visit, terminalID, previousSessionID, sessionID)
265
75
  }
266
76
 
267
77
  func (p *RunProjection) updateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
268
- p.runtimeMu.Lock()
269
- defer p.runtimeMu.Unlock()
270
- _, err := p.DB.ExecContext(ctx, `
271
- INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
272
- VALUES (?, ?, ?, ?)
273
- ON CONFLICT(run_id, node) DO UPDATE SET
274
- node_visit_id = excluded.node_visit_id,
275
- updated_at = excluded.updated_at`,
276
- string(id), node, string(visit), time.Now().UTC())
277
- return err
78
+ return p.shared().UpdateNodeRuntimeVisit(ctx, id, node, visit)
278
79
  }
279
80
 
280
81
  func (p *RunProjection) registerNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (bool, error) {
281
- p.runtimeMu.Lock()
282
- defer p.runtimeMu.Unlock()
283
- tx, err := p.DB.BeginTx(ctx, nil)
284
- if err != nil {
285
- return false, err
286
- }
287
- defer tx.Rollback()
288
- var current run.NodeVisitID
289
- if err := tx.QueryRowContext(ctx, `
290
- SELECT node_visit_id FROM relay_node_runtime WHERE run_id = ? AND node = ?`,
291
- string(registration.RunID), registration.Node).Scan(&current); err != nil {
292
- if errors.Is(err, sql.ErrNoRows) {
293
- return false, nil
294
- }
295
- return false, err
296
- }
297
- if _, err := tx.ExecContext(ctx, `
298
- INSERT INTO relay_node_sessions (run_id, node, session_id, node_visit_id, created_at)
299
- VALUES (?, ?, ?, ?, ?) ON CONFLICT(run_id, node, session_id) DO NOTHING`,
300
- string(registration.RunID), registration.Node, registration.SessionID, string(current), time.Now().UTC()); err != nil {
301
- return false, err
302
- }
303
- var bound run.NodeVisitID
304
- if err := tx.QueryRowContext(ctx, `
305
- SELECT node_visit_id FROM relay_node_sessions
306
- WHERE run_id = ? AND node = ? AND session_id = ?`,
307
- string(registration.RunID), registration.Node, registration.SessionID).Scan(&bound); err != nil {
308
- return false, err
309
- }
310
- if bound != current {
311
- return false, tx.Commit()
312
- }
313
- result, err := tx.ExecContext(ctx, `
314
- UPDATE relay_node_runtime SET session_id = ?, updated_at = ?
315
- WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
316
- registration.SessionID, time.Now().UTC(), string(registration.RunID), registration.Node, string(bound))
317
- if err != nil {
318
- return false, err
319
- }
320
- updated, err := result.RowsAffected()
321
- if err != nil {
322
- return false, err
323
- }
324
- return updated == 1, tx.Commit()
82
+ return p.shared().RegisterNodeSession(ctx, registration)
325
83
  }
326
84
 
327
85
  func (p *RunProjection) hasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
328
- var count int
329
- err := p.DB.QueryRowContext(ctx, `
330
- SELECT COUNT(1) FROM relay_processed_reports WHERE run_id = ? AND report_id = ?`,
331
- string(id), reportID).Scan(&count)
332
- return count == 1, err
86
+ return p.shared().HasProcessedReport(ctx, id, reportID)
333
87
  }
334
88
 
335
89
  func (p *RunProjection) recordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
336
- _, err := p.DB.ExecContext(ctx, `
337
- INSERT INTO relay_processed_reports (run_id, report_id, node_visit_id, created_at)
338
- VALUES (?, ?, ?, ?) ON CONFLICT(run_id, report_id) DO NOTHING`,
339
- string(id), reportID, string(visit), time.Now().UTC())
340
- return err
90
+ return p.shared().RecordProcessedReport(ctx, id, visit, reportID)
341
91
  }
342
92
 
343
93
  func (p *RunProjection) clearNodeRuntime(ctx context.Context, id run.ID, node string, clearTerminal, clearSession bool) error {
344
- _, err := p.DB.ExecContext(ctx, `
345
- UPDATE relay_node_runtime SET
346
- terminal_id = CASE WHEN ? THEN NULL ELSE terminal_id END,
347
- session_id = CASE WHEN ? THEN NULL ELSE session_id END,
348
- updated_at = ?
349
- WHERE run_id = ? AND node = ?`, clearTerminal, clearSession,
350
- time.Now().UTC(), string(id), node)
351
- return err
94
+ return p.shared().ClearNodeRuntime(ctx, id, node, clearTerminal, clearSession)
352
95
  }
353
96
 
354
97
  func (p *RunProjection) listNodeRuntimes(ctx context.Context, id run.ID) ([]NodeRuntime, error) {
355
- rows, err := p.DB.QueryContext(ctx, `
356
- SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
357
- FROM relay_node_runtime WHERE run_id = ?`, string(id))
358
- if err != nil {
359
- return nil, err
360
- }
361
- defer rows.Close()
362
- var out []NodeRuntime
363
- for rows.Next() {
364
- var rt NodeRuntime
365
- var terminalID, sessionID sql.NullString
366
- if err := rows.Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt); err != nil {
367
- return nil, err
368
- }
369
- rt.TerminalID, rt.SessionID = terminalID.String, sessionID.String
370
- out = append(out, rt)
371
- }
372
- return out, rows.Err()
373
- }
374
-
375
- func nullableString(value string) any {
376
- if value == "" {
377
- return nil
378
- }
379
- return value
98
+ return p.shared().ListNodeRuntimes(ctx, id)
380
99
  }
381
100
 
382
101
  func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
383
- row := p.DB.QueryRowContext(ctx, `
384
- SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
385
- retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
386
- FROM relay_runs WHERE id = ?`, string(id))
387
- return scanRun(row)
102
+ return p.shared().Get(ctx, id)
388
103
  }
389
104
 
390
105
  func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
391
- row := p.DB.QueryRowContext(ctx, `
392
- SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
393
- retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
394
- FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, ticket)
395
- return scanRun(row)
106
+ return p.shared().FindByTicket(ctx, ticket)
396
107
  }
397
108
 
398
109
  func (p *RunProjection) findByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
399
- row := p.DB.QueryRowContext(ctx, `
400
- SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
401
- retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
402
- FROM relay_runs WHERE logical_run_id = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, string(logicalID))
403
- return scanRun(row)
404
- }
405
-
406
- type rowScanner interface {
407
- Scan(dest ...any) error
408
- }
409
-
410
- func scanRun(row rowScanner) (run.Run, error) {
411
- var r run.Run
412
- var logicalID sql.NullString
413
- var attemptNumber sql.NullInt64
414
- var node, visit, lastErr, retryErr sql.NullString
415
- var retryAttempt sql.NullInt64
416
- var nextRetry, finished sql.NullTime
417
- var started, updated time.Time
418
- err := row.Scan(&r.ID, &logicalID, &attemptNumber, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
419
- &node, &visit, &lastErr, &retryErr, &retryAttempt, &nextRetry, &started, &updated, &finished)
420
- if errors.Is(err, sql.ErrNoRows) {
421
- return run.Run{}, errRunNotFound
422
- }
423
- if err != nil {
424
- return run.Run{}, err
425
- }
426
- if logicalID.Valid && logicalID.String != "" {
427
- r.LogicalID = run.ID(logicalID.String)
428
- } else {
429
- r.LogicalID = r.ID
430
- }
431
- if attemptNumber.Valid && attemptNumber.Int64 > 0 {
432
- r.AttemptID = run.AttemptID(attemptNumber.Int64)
433
- } else {
434
- r.AttemptID = 1
435
- }
436
- r.CurrentNode = node.String
437
- r.CurrentNodeVisitID = run.NodeVisitID(visit.String)
438
- r.LastError = lastErr.String
439
- if retryErr.Valid && retryAttempt.Valid && nextRetry.Valid {
440
- r.Retry = &run.RetryStatus{
441
- Attempt: int(retryAttempt.Int64), LastError: retryErr.String, NextRetryAt: nextRetry.Time,
442
- }
443
- }
444
- r.StartedAt = started
445
- r.UpdatedAt = updated
446
- if finished.Valid {
447
- t := finished.Time
448
- r.FinishedAt = &t
449
- }
450
- return r, nil
110
+ return p.shared().FindByLogicalID(ctx, logicalID)
451
111
  }
452
112
 
453
- func (p *RunProjection) list(ctx context.Context, f run.Filter) ([]run.Run, error) {
454
- q := `SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error, retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at FROM relay_runs WHERE 1=1`
455
- var args []any
456
- if f.Repo != "" {
457
- q += ` AND repo = ?`
458
- args = append(args, f.Repo)
459
- }
460
- if f.Workflow != "" {
461
- q += ` AND workflow = ?`
462
- args = append(args, f.Workflow)
463
- }
464
- if f.Ticket != "" {
465
- q += ` AND ticket_key = ?`
466
- args = append(args, f.Ticket)
467
- }
468
- if f.Active != nil {
469
- if *f.Active {
470
- q += ` AND state NOT IN ('completed', 'canceled')`
471
- } else {
472
- q += ` AND state IN ('completed', 'canceled')`
473
- }
474
- }
475
- q += ` ORDER BY started_at`
476
- rows, err := p.DB.QueryContext(ctx, q, args...)
477
- if err != nil {
478
- return nil, err
479
- }
480
- defer rows.Close()
481
- var out []run.Run
482
- for rows.Next() {
483
- r, err := scanRun(rows)
484
- if err != nil {
485
- return nil, err
486
- }
487
- out = append(out, r)
488
- }
489
- return out, rows.Err()
113
+ func (p *RunProjection) list(ctx context.Context, filter run.Filter) ([]run.Run, error) {
114
+ return p.shared().List(ctx, filter)
490
115
  }
491
116
 
492
117
  func (p *RunProjection) hasActive(ctx context.Context, column, value string) (bool, error) {
493
- var n int
494
- err := p.DB.QueryRowContext(ctx,
495
- fmt.Sprintf(`SELECT COUNT(1) FROM relay_runs WHERE %s = ? AND state NOT IN ('completed', 'canceled')`, column),
496
- value).Scan(&n)
497
- return n > 0, err
118
+ // Keep this narrow compatibility method for the existing engine; the
119
+ // shared implementation exposes typed workflow/repo query methods.
120
+ if column == "workflow" {
121
+ return p.shared().HasActiveWorkflow(ctx, value)
122
+ }
123
+ return p.shared().HasActiveRepo(ctx, value)
498
124
  }
499
125
 
500
- // sweepRetention removes terminal projection rows whose finished_at is older
501
- // than the retention window. Nonterminal runs are never removed.
502
126
  func (p *RunProjection) sweepRetention(ctx context.Context, olderThan time.Time) ([]string, error) {
503
- rows, err := p.DB.QueryContext(ctx, `
504
- SELECT id FROM relay_runs
505
- WHERE state IN ('completed', 'canceled') AND finished_at IS NOT NULL AND finished_at < ?`, olderThan)
506
- if err != nil {
507
- return nil, err
508
- }
509
- var ids []string
510
- for rows.Next() {
511
- var id string
512
- if err := rows.Scan(&id); err != nil {
513
- rows.Close()
514
- return nil, err
515
- }
516
- ids = append(ids, id)
517
- }
518
- rows.Close()
519
- for _, id := range ids {
520
- tx, err := p.DB.BeginTx(ctx, nil)
521
- if err != nil {
522
- return ids, err
523
- }
524
- if _, err := tx.ExecContext(ctx, `DELETE FROM relay_processed_reports WHERE run_id = ?`, id); err != nil {
525
- tx.Rollback()
526
- return ids, err
527
- }
528
- if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_sessions WHERE run_id = ?`, id); err != nil {
529
- tx.Rollback()
530
- return ids, err
531
- }
532
- if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_runtime WHERE run_id = ?`, id); err != nil {
533
- tx.Rollback()
534
- return ids, err
535
- }
536
- if _, err := tx.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, id); err != nil {
537
- tx.Rollback()
538
- return ids, err
539
- }
540
- if err := tx.Commit(); err != nil {
541
- return ids, err
542
- }
543
- }
544
- return ids, nil
127
+ return p.shared().SweepRetention(ctx, olderThan)
545
128
  }