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
@@ -0,0 +1,867 @@
1
+ package projection
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "os"
9
+ "strings"
10
+ "sync"
11
+ "time"
12
+
13
+ _ "modernc.org/sqlite"
14
+
15
+ "github.com/rajpopat27/relay-flow/internal/run"
16
+ )
17
+
18
+ // RunProjection is the derived relay_runs read model in the same SQLite
19
+ // database as the engine backend. Durable workflow history is authoritative;
20
+ // this table serves application-level queries only. Updates are idempotent
21
+ // durable activities, so replay repairs interrupted updates.
22
+ type RunProjection struct {
23
+ DB *sql.DB
24
+ runtimeMu sync.Mutex
25
+ }
26
+
27
+ func openProjectionDatabase(path string) (*sql.DB, error) {
28
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
29
+ if err != nil {
30
+ return nil, fmt.Errorf("open %s: %w", path, err)
31
+ }
32
+ if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
33
+ db.Close()
34
+ return nil, fmt.Errorf("open %s: %w", path, err)
35
+ }
36
+ if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
37
+ db.Close()
38
+ return nil, fmt.Errorf("open %s: %w", path, err)
39
+ }
40
+ if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
41
+ db.Close()
42
+ return nil, fmt.Errorf("chmod %s: %w", path, err)
43
+ }
44
+ if err := (&RunProjection{DB: db}).Migrate(); err != nil {
45
+ db.Close()
46
+ return nil, fmt.Errorf("migrate relay projection: %w", err)
47
+ }
48
+ return db, nil
49
+ }
50
+
51
+ // InitDatabase creates a relay projection database at path. It is shared by
52
+ // every durable executor; engine-specific history tables are owned by the
53
+ // selected executor and are not created here.
54
+ func InitDatabase(path string) error {
55
+ db, err := openProjectionDatabase(path)
56
+ if err != nil {
57
+ return err
58
+ }
59
+ return db.Close()
60
+ }
61
+
62
+ // InitDatabaseWithIdentity initializes the shared relay projection and writes
63
+ // its immutable executor marker before reporting initialization success. The
64
+ // marker write itself is transactional and uses the same open database that
65
+ // created the projection.
66
+ func InitDatabaseWithIdentity(path string, identity ExecutorIdentity) error {
67
+ if err := validateExecutorIdentity(identity); err != nil {
68
+ return err
69
+ }
70
+ db, err := openProjectionDatabase(path)
71
+ if err != nil {
72
+ return err
73
+ }
74
+ defer db.Close()
75
+ return (&RunProjection{DB: db}).InitializeIdentity(context.Background(), identity)
76
+ }
77
+
78
+ // HasNonterminalRuns checks a valid existing projection without migrating it.
79
+ func HasNonterminalRuns(path string) (bool, error) {
80
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
81
+ if err != nil {
82
+ return false, fmt.Errorf("open %s: %w", path, err)
83
+ }
84
+ defer db.Close()
85
+ var active bool
86
+ if err := db.QueryRow(`SELECT EXISTS(
87
+ SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
88
+ )`).Scan(&active); err != nil {
89
+ return false, fmt.Errorf("inspect %s: %w", path, err)
90
+ }
91
+ return active, nil
92
+ }
93
+
94
+ // NodeRuntime is the durable runtime identity for one node in a run. Unlike
95
+ // relay_runs' current-node fields, one row is retained for every visited node.
96
+ type NodeRuntime struct {
97
+ RunID run.ID
98
+ Node string
99
+ TerminalID string
100
+ SessionID string
101
+ NodeVisitID run.NodeVisitID
102
+ UpdatedAt time.Time
103
+ }
104
+
105
+ const relayRunsSchema = `
106
+ CREATE TABLE IF NOT EXISTS relay_executor_identity (
107
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
108
+ executor_plugin TEXT NOT NULL,
109
+ temporal_address TEXT,
110
+ temporal_namespace TEXT
111
+ );
112
+ CREATE TABLE IF NOT EXISTS relay_runs (
113
+ id TEXT PRIMARY KEY,
114
+ logical_run_id TEXT,
115
+ attempt_id INTEGER,
116
+ repo TEXT NOT NULL,
117
+ workflow TEXT NOT NULL,
118
+ ticket_id TEXT NOT NULL,
119
+ ticket_key TEXT NOT NULL,
120
+ state TEXT NOT NULL,
121
+ current_node TEXT,
122
+ current_node_visit_id TEXT,
123
+ last_error TEXT,
124
+ retry_error TEXT,
125
+ retry_attempt INTEGER,
126
+ next_retry_at DATETIME,
127
+ started_at DATETIME NOT NULL,
128
+ updated_at DATETIME NOT NULL,
129
+ finished_at DATETIME
130
+ );
131
+ CREATE INDEX IF NOT EXISTS relay_runs_ticket_key ON relay_runs (ticket_key);
132
+ CREATE INDEX IF NOT EXISTS relay_runs_workflow_state ON relay_runs (workflow, state);
133
+ CREATE INDEX IF NOT EXISTS relay_runs_repo_state ON relay_runs (repo, state);
134
+ CREATE TABLE IF NOT EXISTS relay_node_runtime (
135
+ run_id TEXT NOT NULL,
136
+ node TEXT NOT NULL,
137
+ terminal_id TEXT,
138
+ session_id TEXT,
139
+ node_visit_id TEXT NOT NULL,
140
+ updated_at DATETIME NOT NULL,
141
+ PRIMARY KEY (run_id, node),
142
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
143
+ );
144
+ CREATE TABLE IF NOT EXISTS relay_processed_reports (
145
+ run_id TEXT NOT NULL,
146
+ report_id TEXT NOT NULL,
147
+ node_visit_id TEXT NOT NULL,
148
+ created_at DATETIME NOT NULL,
149
+ PRIMARY KEY (run_id, report_id),
150
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
151
+ );
152
+ CREATE TABLE IF NOT EXISTS relay_node_sessions (
153
+ run_id TEXT NOT NULL,
154
+ node TEXT NOT NULL,
155
+ session_id TEXT NOT NULL,
156
+ node_visit_id TEXT NOT NULL,
157
+ created_at DATETIME NOT NULL,
158
+ PRIMARY KEY (run_id, node, session_id),
159
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
160
+ );
161
+ `
162
+
163
+ func (p *RunProjection) migrate() error {
164
+ if _, err := p.DB.Exec(relayRunsSchema); err != nil {
165
+ return err
166
+ }
167
+ for name, definition := range map[string]string{
168
+ "logical_run_id": "TEXT",
169
+ "attempt_id": "INTEGER",
170
+ "retry_error": "TEXT",
171
+ "retry_attempt": "INTEGER",
172
+ "next_retry_at": "DATETIME",
173
+ } {
174
+ var count int
175
+ if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
176
+ return err
177
+ }
178
+ if count == 0 {
179
+ if _, err := p.DB.Exec(`ALTER TABLE relay_runs ADD COLUMN ` + name + ` ` + definition); err != nil {
180
+ return err
181
+ }
182
+ }
183
+ }
184
+ // Rows created before attempt identities were introduced represent the
185
+ // original attempt. Backfill the stable logical ID and attempt number so
186
+ // restart allocation remains numeric and never reuses attempt 1.
187
+ if _, err := p.DB.Exec(`UPDATE relay_runs SET logical_run_id = id WHERE COALESCE(logical_run_id, '') = ''`); err != nil {
188
+ return err
189
+ }
190
+ if _, err := p.DB.Exec(`UPDATE relay_runs SET attempt_id = 1 WHERE attempt_id IS NULL OR attempt_id = 0`); err != nil {
191
+ return err
192
+ }
193
+ return nil
194
+ }
195
+
196
+ var errRunNotFound = errors.New("run not found")
197
+ var errNodeRuntimeNotFound = errors.New("node runtime not found")
198
+
199
+ // IsNotFound reports a missing projection row.
200
+ func IsNotFound(err error) bool { return errors.Is(err, errRunNotFound) }
201
+
202
+ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.Time) error {
203
+ logicalID := s.LogicalID
204
+ if logicalID == "" {
205
+ logicalID = s.ID
206
+ }
207
+ attemptID := s.AttemptID
208
+ if attemptID == 0 {
209
+ attemptID = 1
210
+ }
211
+ _, err := p.DB.ExecContext(ctx, `
212
+ INSERT INTO relay_runs (id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
213
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
214
+ ON CONFLICT(id) DO NOTHING`,
215
+ string(s.ID), string(logicalID), int64(attemptID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
216
+ string(run.StateStarting), now, now)
217
+ return err
218
+ }
219
+
220
+ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
221
+ terminal := state == run.StateCompleted || state == run.StateCanceled
222
+ _, err := p.DB.ExecContext(ctx, `
223
+ UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
224
+ retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
225
+ retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
226
+ next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
227
+ WHERE id = ?`,
228
+ string(state), lastErr, time.Now().UTC(), finished, terminal, terminal, terminal, string(id))
229
+ return err
230
+ }
231
+
232
+ func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
233
+ if status == nil {
234
+ _, err := p.DB.ExecContext(ctx, `
235
+ UPDATE relay_runs SET retry_error = NULL, retry_attempt = NULL, next_retry_at = NULL, updated_at = ?
236
+ WHERE id = ?`, time.Now().UTC(), string(id))
237
+ return err
238
+ }
239
+ _, err := p.DB.ExecContext(ctx, `
240
+ UPDATE relay_runs SET retry_error = ?, retry_attempt = ?, next_retry_at = ?, updated_at = ?
241
+ WHERE id = ?`, status.LastError, status.Attempt, status.NextRetryAt, time.Now().UTC(), string(id))
242
+ return err
243
+ }
244
+
245
+ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
246
+ p.runtimeMu.Lock()
247
+ defer p.runtimeMu.Unlock()
248
+ tx, err := p.DB.BeginTx(ctx, nil)
249
+ if err != nil {
250
+ return err
251
+ }
252
+ defer tx.Rollback()
253
+ now := time.Now().UTC()
254
+ if _, err := tx.ExecContext(ctx, `
255
+ UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
256
+ WHERE id = ?`,
257
+ string(state), node, string(visit), now, string(id)); err != nil {
258
+ return err
259
+ }
260
+ // A revisit changes only the latest visit ID. Reusable terminal/session
261
+ // identities remain attached to this run/node row.
262
+ if _, err := tx.ExecContext(ctx, `
263
+ INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
264
+ VALUES (?, ?, ?, ?)
265
+ ON CONFLICT(run_id, node) DO UPDATE SET
266
+ node_visit_id = excluded.node_visit_id,
267
+ updated_at = excluded.updated_at`,
268
+ string(id), node, string(visit), now); err != nil {
269
+ return err
270
+ }
271
+ return tx.Commit()
272
+ }
273
+
274
+ func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
275
+ var rt NodeRuntime
276
+ var terminalID, sessionID sql.NullString
277
+ err := p.DB.QueryRowContext(ctx, `
278
+ SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
279
+ FROM relay_node_runtime WHERE run_id = ? AND node = ?`, string(id), node).
280
+ Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt)
281
+ if errors.Is(err, sql.ErrNoRows) {
282
+ return NodeRuntime{}, errNodeRuntimeNotFound
283
+ }
284
+ if err != nil {
285
+ return NodeRuntime{}, err
286
+ }
287
+ rt.TerminalID = terminalID.String
288
+ rt.SessionID = sessionID.String
289
+ return rt, nil
290
+ }
291
+
292
+ func (p *RunProjection) updateNodeRuntime(ctx context.Context, rt NodeRuntime) error {
293
+ _, err := p.DB.ExecContext(ctx, `
294
+ INSERT INTO relay_node_runtime (run_id, node, terminal_id, session_id, node_visit_id, updated_at)
295
+ VALUES (?, ?, ?, ?, ?, ?)
296
+ ON CONFLICT(run_id, node) DO UPDATE SET
297
+ terminal_id = excluded.terminal_id,
298
+ session_id = excluded.session_id,
299
+ node_visit_id = excluded.node_visit_id,
300
+ updated_at = excluded.updated_at`,
301
+ string(rt.RunID), rt.Node, nullableString(rt.TerminalID), nullableString(rt.SessionID),
302
+ string(rt.NodeVisitID), time.Now().UTC())
303
+ return err
304
+ }
305
+
306
+ func (p *RunProjection) loadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
307
+ rt, err := p.getNodeRuntime(ctx, id, node)
308
+ if errors.Is(err, errNodeRuntimeNotFound) {
309
+ return NodeRuntime{RunID: id, Node: node}, nil
310
+ }
311
+ return rt, err
312
+ }
313
+
314
+ func (p *RunProjection) nodeRuntimeVisitIsCurrent(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) (bool, error) {
315
+ var count int
316
+ err := p.DB.QueryRowContext(ctx, `
317
+ SELECT COUNT(1) FROM relay_node_runtime
318
+ WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
319
+ string(id), node, string(visit)).Scan(&count)
320
+ return count == 1, err
321
+ }
322
+
323
+ func (p *RunProjection) replaceNodeRuntime(ctx context.Context, id run.ID, node string, visit run.NodeVisitID, terminalID, previousSessionID, sessionID string) error {
324
+ result, err := p.DB.ExecContext(ctx, `
325
+ UPDATE relay_node_runtime SET terminal_id = ?,
326
+ session_id = CASE WHEN COALESCE(session_id, '') = ? THEN ? ELSE session_id END,
327
+ updated_at = ?
328
+ WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
329
+ nullableString(terminalID), previousSessionID, nullableString(sessionID), time.Now().UTC(),
330
+ string(id), node, string(visit))
331
+ if err != nil {
332
+ return err
333
+ }
334
+ updated, err := result.RowsAffected()
335
+ if err != nil {
336
+ return err
337
+ }
338
+ if updated != 1 {
339
+ return fmt.Errorf("node runtime %s/%s visit %s is not current", id, node, visit)
340
+ }
341
+ return nil
342
+ }
343
+
344
+ func (p *RunProjection) updateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
345
+ p.runtimeMu.Lock()
346
+ defer p.runtimeMu.Unlock()
347
+ _, err := p.DB.ExecContext(ctx, `
348
+ INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
349
+ VALUES (?, ?, ?, ?)
350
+ ON CONFLICT(run_id, node) DO UPDATE SET
351
+ node_visit_id = excluded.node_visit_id,
352
+ updated_at = excluded.updated_at`,
353
+ string(id), node, string(visit), time.Now().UTC())
354
+ return err
355
+ }
356
+
357
+ func (p *RunProjection) registerNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (bool, error) {
358
+ p.runtimeMu.Lock()
359
+ defer p.runtimeMu.Unlock()
360
+ tx, err := p.DB.BeginTx(ctx, nil)
361
+ if err != nil {
362
+ return false, err
363
+ }
364
+ defer tx.Rollback()
365
+ var current run.NodeVisitID
366
+ if err := tx.QueryRowContext(ctx, `
367
+ SELECT node_visit_id FROM relay_node_runtime WHERE run_id = ? AND node = ?`,
368
+ string(registration.RunID), registration.Node).Scan(&current); err != nil {
369
+ if errors.Is(err, sql.ErrNoRows) {
370
+ return false, nil
371
+ }
372
+ return false, err
373
+ }
374
+ if _, err := tx.ExecContext(ctx, `
375
+ INSERT INTO relay_node_sessions (run_id, node, session_id, node_visit_id, created_at)
376
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT(run_id, node, session_id) DO NOTHING`,
377
+ string(registration.RunID), registration.Node, registration.SessionID, string(current), time.Now().UTC()); err != nil {
378
+ return false, err
379
+ }
380
+ var bound run.NodeVisitID
381
+ if err := tx.QueryRowContext(ctx, `
382
+ SELECT node_visit_id FROM relay_node_sessions
383
+ WHERE run_id = ? AND node = ? AND session_id = ?`,
384
+ string(registration.RunID), registration.Node, registration.SessionID).Scan(&bound); err != nil {
385
+ return false, err
386
+ }
387
+ if bound != current {
388
+ return false, tx.Commit()
389
+ }
390
+ result, err := tx.ExecContext(ctx, `
391
+ UPDATE relay_node_runtime SET session_id = ?, updated_at = ?
392
+ WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
393
+ registration.SessionID, time.Now().UTC(), string(registration.RunID), registration.Node, string(bound))
394
+ if err != nil {
395
+ return false, err
396
+ }
397
+ updated, err := result.RowsAffected()
398
+ if err != nil {
399
+ return false, err
400
+ }
401
+ return updated == 1, tx.Commit()
402
+ }
403
+
404
+ func (p *RunProjection) hasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
405
+ var count int
406
+ err := p.DB.QueryRowContext(ctx, `
407
+ SELECT COUNT(1) FROM relay_processed_reports WHERE run_id = ? AND report_id = ?`,
408
+ string(id), reportID).Scan(&count)
409
+ return count == 1, err
410
+ }
411
+
412
+ func (p *RunProjection) recordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
413
+ _, err := p.DB.ExecContext(ctx, `
414
+ INSERT INTO relay_processed_reports (run_id, report_id, node_visit_id, created_at)
415
+ VALUES (?, ?, ?, ?) ON CONFLICT(run_id, report_id) DO NOTHING`,
416
+ string(id), reportID, string(visit), time.Now().UTC())
417
+ return err
418
+ }
419
+
420
+ func (p *RunProjection) clearNodeRuntime(ctx context.Context, id run.ID, node string, clearTerminal, clearSession bool) error {
421
+ _, err := p.DB.ExecContext(ctx, `
422
+ UPDATE relay_node_runtime SET
423
+ terminal_id = CASE WHEN ? THEN NULL ELSE terminal_id END,
424
+ session_id = CASE WHEN ? THEN NULL ELSE session_id END,
425
+ updated_at = ?
426
+ WHERE run_id = ? AND node = ?`, clearTerminal, clearSession,
427
+ time.Now().UTC(), string(id), node)
428
+ return err
429
+ }
430
+
431
+ func (p *RunProjection) listNodeRuntimes(ctx context.Context, id run.ID) ([]NodeRuntime, error) {
432
+ rows, err := p.DB.QueryContext(ctx, `
433
+ SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
434
+ FROM relay_node_runtime WHERE run_id = ?`, string(id))
435
+ if err != nil {
436
+ return nil, err
437
+ }
438
+ defer rows.Close()
439
+ var out []NodeRuntime
440
+ for rows.Next() {
441
+ var rt NodeRuntime
442
+ var terminalID, sessionID sql.NullString
443
+ if err := rows.Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt); err != nil {
444
+ return nil, err
445
+ }
446
+ rt.TerminalID, rt.SessionID = terminalID.String, sessionID.String
447
+ out = append(out, rt)
448
+ }
449
+ return out, rows.Err()
450
+ }
451
+
452
+ func nullableString(value string) any {
453
+ if value == "" {
454
+ return nil
455
+ }
456
+ return value
457
+ }
458
+
459
+ func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
460
+ row := p.DB.QueryRowContext(ctx, `
461
+ SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
462
+ retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
463
+ FROM relay_runs WHERE id = ?`, string(id))
464
+ return scanRun(row)
465
+ }
466
+
467
+ func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
468
+ row := p.DB.QueryRowContext(ctx, `
469
+ SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
470
+ retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
471
+ FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, ticket)
472
+ return scanRun(row)
473
+ }
474
+
475
+ func (p *RunProjection) findByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
476
+ row := p.DB.QueryRowContext(ctx, `
477
+ SELECT id, logical_run_id, attempt_id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
478
+ retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
479
+ FROM relay_runs WHERE logical_run_id = ? ORDER BY started_at DESC, attempt_id DESC LIMIT 1`, string(logicalID))
480
+ return scanRun(row)
481
+ }
482
+
483
+ type rowScanner interface {
484
+ Scan(dest ...any) error
485
+ }
486
+
487
+ func scanRun(row rowScanner) (run.Run, error) {
488
+ var r run.Run
489
+ var logicalID sql.NullString
490
+ var attemptNumber sql.NullInt64
491
+ var node, visit, lastErr, retryErr sql.NullString
492
+ var retryAttempt sql.NullInt64
493
+ var nextRetry, finished sql.NullTime
494
+ var started, updated time.Time
495
+ err := row.Scan(&r.ID, &logicalID, &attemptNumber, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
496
+ &node, &visit, &lastErr, &retryErr, &retryAttempt, &nextRetry, &started, &updated, &finished)
497
+ if errors.Is(err, sql.ErrNoRows) {
498
+ return run.Run{}, errRunNotFound
499
+ }
500
+ if err != nil {
501
+ return run.Run{}, err
502
+ }
503
+ if logicalID.Valid && logicalID.String != "" {
504
+ r.LogicalID = run.ID(logicalID.String)
505
+ } else {
506
+ r.LogicalID = r.ID
507
+ }
508
+ if attemptNumber.Valid && attemptNumber.Int64 > 0 {
509
+ r.AttemptID = run.AttemptID(attemptNumber.Int64)
510
+ } else {
511
+ r.AttemptID = 1
512
+ }
513
+ r.CurrentNode = node.String
514
+ r.CurrentNodeVisitID = run.NodeVisitID(visit.String)
515
+ r.LastError = lastErr.String
516
+ if retryErr.Valid && retryAttempt.Valid && nextRetry.Valid {
517
+ r.Retry = &run.RetryStatus{
518
+ Attempt: int(retryAttempt.Int64), LastError: retryErr.String, NextRetryAt: nextRetry.Time,
519
+ }
520
+ }
521
+ r.StartedAt = started
522
+ r.UpdatedAt = updated
523
+ if finished.Valid {
524
+ t := finished.Time
525
+ r.FinishedAt = &t
526
+ }
527
+ return r, nil
528
+ }
529
+
530
+ func (p *RunProjection) list(ctx context.Context, f run.Filter) ([]run.Run, error) {
531
+ 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`
532
+ var args []any
533
+ if f.Repo != "" {
534
+ q += ` AND repo = ?`
535
+ args = append(args, f.Repo)
536
+ }
537
+ if f.Workflow != "" {
538
+ q += ` AND workflow = ?`
539
+ args = append(args, f.Workflow)
540
+ }
541
+ if f.Ticket != "" {
542
+ q += ` AND ticket_key = ?`
543
+ args = append(args, f.Ticket)
544
+ }
545
+ if f.Active != nil {
546
+ if *f.Active {
547
+ q += ` AND state NOT IN ('completed', 'canceled')`
548
+ } else {
549
+ q += ` AND state IN ('completed', 'canceled')`
550
+ }
551
+ }
552
+ q += ` ORDER BY started_at`
553
+ rows, err := p.DB.QueryContext(ctx, q, args...)
554
+ if err != nil {
555
+ return nil, err
556
+ }
557
+ defer rows.Close()
558
+ var out []run.Run
559
+ for rows.Next() {
560
+ r, err := scanRun(rows)
561
+ if err != nil {
562
+ return nil, err
563
+ }
564
+ out = append(out, r)
565
+ }
566
+ return out, rows.Err()
567
+ }
568
+
569
+ func (p *RunProjection) hasActive(ctx context.Context, column, value string) (bool, error) {
570
+ var n int
571
+ err := p.DB.QueryRowContext(ctx,
572
+ fmt.Sprintf(`SELECT COUNT(1) FROM relay_runs WHERE %s = ? AND state NOT IN ('completed', 'canceled')`, column),
573
+ value).Scan(&n)
574
+ return n > 0, err
575
+ }
576
+
577
+ // sweepRetention removes terminal projection rows whose finished_at is older
578
+ // than the retention window. Nonterminal runs are never removed.
579
+ func (p *RunProjection) sweepRetention(ctx context.Context, olderThan time.Time) ([]string, error) {
580
+ rows, err := p.DB.QueryContext(ctx, `
581
+ SELECT id FROM relay_runs
582
+ WHERE state IN ('completed', 'canceled') AND finished_at IS NOT NULL AND finished_at < ?`, olderThan)
583
+ if err != nil {
584
+ return nil, err
585
+ }
586
+ var ids []string
587
+ for rows.Next() {
588
+ var id string
589
+ if err := rows.Scan(&id); err != nil {
590
+ rows.Close()
591
+ return nil, err
592
+ }
593
+ ids = append(ids, id)
594
+ }
595
+ rows.Close()
596
+ for _, id := range ids {
597
+ tx, err := p.DB.BeginTx(ctx, nil)
598
+ if err != nil {
599
+ return ids, err
600
+ }
601
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_processed_reports WHERE run_id = ?`, id); err != nil {
602
+ tx.Rollback()
603
+ return ids, err
604
+ }
605
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_sessions WHERE run_id = ?`, id); err != nil {
606
+ tx.Rollback()
607
+ return ids, err
608
+ }
609
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_runtime WHERE run_id = ?`, id); err != nil {
610
+ tx.Rollback()
611
+ return ids, err
612
+ }
613
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, id); err != nil {
614
+ tx.Rollback()
615
+ return ids, err
616
+ }
617
+ if err := tx.Commit(); err != nil {
618
+ return ids, err
619
+ }
620
+ }
621
+ return ids, nil
622
+ }
623
+
624
+ // The exported methods below are the engine-neutral projection surface. The
625
+ // small unexported methods above retain the original implementation shape so
626
+ // the goworkflows facade can preserve its package-local call sites without
627
+ // copying the schema or SQL.
628
+
629
+ // Migrate creates or upgrades all relay-owned projection tables.
630
+ func (p *RunProjection) Migrate() error { return p.migrate() }
631
+
632
+ // InsertStart records a starting run idempotently.
633
+ func (p *RunProjection) InsertStart(ctx context.Context, start run.Start, now time.Time) error {
634
+ return p.insertStart(ctx, start, now)
635
+ }
636
+
637
+ // UpdateState updates the lifecycle state and terminal metadata.
638
+ func (p *RunProjection) UpdateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
639
+ return p.updateState(ctx, id, state, lastErr, finished)
640
+ }
641
+
642
+ // UpdateRetry updates or clears active retry metadata.
643
+ func (p *RunProjection) UpdateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
644
+ return p.updateRetry(ctx, id, status)
645
+ }
646
+
647
+ // UpdateNode updates the current node and its latest visit identity.
648
+ func (p *RunProjection) UpdateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
649
+ return p.updateNode(ctx, id, state, node, visit)
650
+ }
651
+
652
+ // GetNodeRuntime returns one node's persisted runtime binding.
653
+ func (p *RunProjection) GetNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
654
+ return p.getNodeRuntime(ctx, id, node)
655
+ }
656
+
657
+ // UpdateNodeRuntime upserts a node's persisted runtime binding.
658
+ func (p *RunProjection) UpdateNodeRuntime(ctx context.Context, runtime NodeRuntime) error {
659
+ return p.updateNodeRuntime(ctx, runtime)
660
+ }
661
+
662
+ // LoadNodeRuntime loads a runtime binding, returning an empty binding when the
663
+ // node has not been seen yet.
664
+ func (p *RunProjection) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
665
+ return p.loadNodeRuntime(ctx, id, node)
666
+ }
667
+
668
+ // NodeRuntimeVisitIsCurrent reports whether visit is the current node visit.
669
+ func (p *RunProjection) NodeRuntimeVisitIsCurrent(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) (bool, error) {
670
+ return p.nodeRuntimeVisitIsCurrent(ctx, id, node, visit)
671
+ }
672
+
673
+ // ReplaceNodeRuntime replaces runtime handles for the current visit.
674
+ func (p *RunProjection) ReplaceNodeRuntime(ctx context.Context, id run.ID, node string, visit run.NodeVisitID, terminalID, previousSessionID, sessionID string) error {
675
+ return p.replaceNodeRuntime(ctx, id, node, visit, terminalID, previousSessionID, sessionID)
676
+ }
677
+
678
+ // UpdateNodeRuntimeVisit updates only the current visit while preserving
679
+ // reusable terminal and session handles.
680
+ func (p *RunProjection) UpdateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
681
+ return p.updateNodeRuntimeVisit(ctx, id, node, visit)
682
+ }
683
+
684
+ // RegisterNodeSession records a runtime session and binds it only when its
685
+ // visit is still current.
686
+ func (p *RunProjection) RegisterNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (bool, error) {
687
+ return p.registerNodeSession(ctx, registration)
688
+ }
689
+
690
+ // HasProcessedReport checks the derived report receipt fast path.
691
+ func (p *RunProjection) HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
692
+ return p.hasProcessedReport(ctx, id, reportID)
693
+ }
694
+
695
+ // RecordProcessedReport records a report receipt idempotently.
696
+ func (p *RunProjection) RecordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
697
+ return p.recordProcessedReport(ctx, id, visit, reportID)
698
+ }
699
+
700
+ // ClearNodeRuntime clears selected reusable runtime handles.
701
+ func (p *RunProjection) ClearNodeRuntime(ctx context.Context, id run.ID, node string, clearTerminal, clearSession bool) error {
702
+ return p.clearNodeRuntime(ctx, id, node, clearTerminal, clearSession)
703
+ }
704
+
705
+ // ListNodeRuntimes returns all persisted bindings for one run.
706
+ func (p *RunProjection) ListNodeRuntimes(ctx context.Context, id run.ID) ([]NodeRuntime, error) {
707
+ return p.listNodeRuntimes(ctx, id)
708
+ }
709
+
710
+ // Get returns one derived run row.
711
+ func (p *RunProjection) Get(ctx context.Context, id run.ID) (run.Run, error) {
712
+ return p.get(ctx, id)
713
+ }
714
+
715
+ // FindByTicket returns the latest derived run for a ticket.
716
+ func (p *RunProjection) FindByTicket(ctx context.Context, ticket string) (run.Run, error) {
717
+ return p.findByTicket(ctx, ticket)
718
+ }
719
+
720
+ // FindByLogicalID returns the latest attempt for a logical run.
721
+ func (p *RunProjection) FindByLogicalID(ctx context.Context, logicalID run.ID) (run.Run, error) {
722
+ return p.findByLogicalID(ctx, logicalID)
723
+ }
724
+
725
+ // List returns derived runs matching filter.
726
+ func (p *RunProjection) List(ctx context.Context, filter run.Filter) ([]run.Run, error) {
727
+ return p.list(ctx, filter)
728
+ }
729
+
730
+ // HasActiveWorkflow reports whether a nonterminal run exists for workflow.
731
+ func (p *RunProjection) HasActiveWorkflow(ctx context.Context, workflow string) (bool, error) {
732
+ return p.hasActive(ctx, "workflow", workflow)
733
+ }
734
+
735
+ // HasActiveRepo reports whether a nonterminal run exists for repo.
736
+ func (p *RunProjection) HasActiveRepo(ctx context.Context, repoName string) (bool, error) {
737
+ return p.hasActive(ctx, "repo", repoName)
738
+ }
739
+
740
+ // SweepRetention deletes terminal rows older than olderThan and their derived
741
+ // child rows. It never removes active lifecycle states.
742
+ func (p *RunProjection) SweepRetention(ctx context.Context, olderThan time.Time) ([]string, error) {
743
+ return p.sweepRetention(ctx, olderThan)
744
+ }
745
+
746
+ // ErrRunNotFound identifies a missing derived run row.
747
+ var ErrRunNotFound = errRunNotFound
748
+
749
+ // ErrNodeRuntimeNotFound identifies a missing node runtime row.
750
+ var ErrNodeRuntimeNotFound = errNodeRuntimeNotFound
751
+
752
+ // ExecutorIdentity is the immutable durable-execution identity of one
753
+ // initialized relay-flow home.
754
+ type ExecutorIdentity struct {
755
+ ExecutorPlugin string
756
+ TemporalAddress string
757
+ TemporalNamespace string
758
+ }
759
+
760
+ // ErrIdentityMissing means a database has no installation identity marker.
761
+ var ErrIdentityMissing = errors.New("executor identity missing")
762
+
763
+ // ErrIdentityMismatch means configured execution identity differs from the
764
+ // initialized installation marker.
765
+ var ErrIdentityMismatch = errors.New("executor identity mismatch")
766
+
767
+ func validateExecutorIdentity(identity ExecutorIdentity) error {
768
+ switch identity.ExecutorPlugin {
769
+ case "goworkflows":
770
+ if identity.TemporalAddress != "" || identity.TemporalNamespace != "" {
771
+ return fmt.Errorf("goworkflows identity must not contain Temporal address or namespace")
772
+ }
773
+ case "temporal":
774
+ if strings.TrimSpace(identity.TemporalAddress) == "" || strings.TrimSpace(identity.TemporalNamespace) == "" {
775
+ return fmt.Errorf("temporal identity requires address and namespace")
776
+ }
777
+ default:
778
+ return fmt.Errorf("unknown executor plugin %q", identity.ExecutorPlugin)
779
+ }
780
+ return nil
781
+ }
782
+
783
+ // InitializeIdentity atomically creates the singleton marker or verifies that
784
+ // an existing marker has exactly the same durable identity. It never updates a
785
+ // marker in place, so a changed executor/address/namespace fails closed.
786
+ func (p *RunProjection) InitializeIdentity(ctx context.Context, expected ExecutorIdentity) error {
787
+ if err := validateExecutorIdentity(expected); err != nil {
788
+ return err
789
+ }
790
+ tx, err := p.DB.BeginTx(ctx, nil)
791
+ if err != nil {
792
+ return fmt.Errorf("begin executor identity transaction: %w", err)
793
+ }
794
+ defer tx.Rollback()
795
+
796
+ var actual ExecutorIdentity
797
+ var address, namespace sql.NullString
798
+ err = tx.QueryRowContext(ctx, `
799
+ SELECT executor_plugin, temporal_address, temporal_namespace
800
+ FROM relay_executor_identity WHERE singleton = 1`).Scan(
801
+ &actual.ExecutorPlugin, &address, &namespace)
802
+ switch {
803
+ case errors.Is(err, sql.ErrNoRows):
804
+ _, err = tx.ExecContext(ctx, `
805
+ INSERT INTO relay_executor_identity
806
+ (singleton, executor_plugin, temporal_address, temporal_namespace)
807
+ VALUES (1, ?, ?, ?)`, expected.ExecutorPlugin, nullableString(expected.TemporalAddress), nullableString(expected.TemporalNamespace))
808
+ if err != nil {
809
+ return fmt.Errorf("insert executor identity: %w", err)
810
+ }
811
+ case err != nil:
812
+ return fmt.Errorf("read executor identity: %w", err)
813
+ default:
814
+ actual.TemporalAddress = address.String
815
+ actual.TemporalNamespace = namespace.String
816
+ if actual != expected {
817
+ return fmt.Errorf("%w: configured=%+v persisted=%+v", ErrIdentityMismatch, expected, actual)
818
+ }
819
+ }
820
+ if err := tx.Commit(); err != nil {
821
+ return fmt.Errorf("commit executor identity: %w", err)
822
+ }
823
+ return nil
824
+ }
825
+
826
+ // VerifyIdentity checks the singleton marker before worker startup. A legacy
827
+ // marker-less database may be adopted only by the embedded executor; this
828
+ // first successful verification records that legacy identity.
829
+ func (p *RunProjection) VerifyIdentity(ctx context.Context, expected ExecutorIdentity) error {
830
+ if err := validateExecutorIdentity(expected); err != nil {
831
+ return err
832
+ }
833
+ actual, present, err := p.Identity(ctx)
834
+ if err != nil {
835
+ return err
836
+ }
837
+ if !present {
838
+ if expected.ExecutorPlugin != "goworkflows" {
839
+ return fmt.Errorf("%w: Temporal installation requires an initialized marker", ErrIdentityMissing)
840
+ }
841
+ return p.InitializeIdentity(ctx, expected)
842
+ }
843
+ if actual != expected {
844
+ return fmt.Errorf("%w: configured=%+v persisted=%+v", ErrIdentityMismatch, expected, actual)
845
+ }
846
+ return nil
847
+ }
848
+
849
+ // Identity reads the singleton marker. The boolean is false when the marker is
850
+ // absent; absence is distinct from a malformed/missing table error.
851
+ func (p *RunProjection) Identity(ctx context.Context) (ExecutorIdentity, bool, error) {
852
+ var identity ExecutorIdentity
853
+ var address, namespace sql.NullString
854
+ err := p.DB.QueryRowContext(ctx, `
855
+ SELECT executor_plugin, temporal_address, temporal_namespace
856
+ FROM relay_executor_identity WHERE singleton = 1`).Scan(
857
+ &identity.ExecutorPlugin, &address, &namespace)
858
+ if errors.Is(err, sql.ErrNoRows) {
859
+ return ExecutorIdentity{}, false, nil
860
+ }
861
+ if err != nil {
862
+ return ExecutorIdentity{}, false, fmt.Errorf("read executor identity: %w", err)
863
+ }
864
+ identity.TemporalAddress = address.String
865
+ identity.TemporalNamespace = namespace.String
866
+ return identity, true, nil
867
+ }