relay-flow 0.0.1 → 0.2.0-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 (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,504 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "sync"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ )
13
+
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.
18
+ type RunProjection struct {
19
+ DB *sql.DB
20
+ runtimeMu sync.Mutex
21
+ }
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
+ }
33
+
34
+ const relayRunsSchema = `
35
+ CREATE TABLE IF NOT EXISTS relay_runs (
36
+ id TEXT PRIMARY KEY,
37
+ repo TEXT NOT NULL,
38
+ workflow TEXT NOT NULL,
39
+ ticket_id TEXT NOT NULL,
40
+ ticket_key TEXT NOT NULL,
41
+ state TEXT NOT NULL,
42
+ current_node TEXT,
43
+ current_node_visit_id TEXT,
44
+ last_error TEXT,
45
+ retry_error TEXT,
46
+ retry_attempt INTEGER,
47
+ next_retry_at DATETIME,
48
+ started_at DATETIME NOT NULL,
49
+ updated_at DATETIME NOT NULL,
50
+ finished_at DATETIME
51
+ );
52
+ CREATE INDEX IF NOT EXISTS relay_runs_ticket_key ON relay_runs (ticket_key);
53
+ CREATE INDEX IF NOT EXISTS relay_runs_workflow_state ON relay_runs (workflow, state);
54
+ CREATE INDEX IF NOT EXISTS relay_runs_repo_state ON relay_runs (repo, state);
55
+ CREATE TABLE IF NOT EXISTS relay_node_runtime (
56
+ run_id TEXT NOT NULL,
57
+ node TEXT NOT NULL,
58
+ terminal_id TEXT,
59
+ session_id TEXT,
60
+ node_visit_id TEXT NOT NULL,
61
+ updated_at DATETIME NOT NULL,
62
+ PRIMARY KEY (run_id, node),
63
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
64
+ );
65
+ CREATE TABLE IF NOT EXISTS relay_processed_reports (
66
+ run_id TEXT NOT NULL,
67
+ report_id TEXT NOT NULL,
68
+ node_visit_id TEXT NOT NULL,
69
+ created_at DATETIME NOT NULL,
70
+ PRIMARY KEY (run_id, report_id),
71
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
72
+ );
73
+ CREATE TABLE IF NOT EXISTS relay_node_sessions (
74
+ run_id TEXT NOT NULL,
75
+ node TEXT NOT NULL,
76
+ session_id TEXT NOT NULL,
77
+ node_visit_id TEXT NOT NULL,
78
+ created_at DATETIME NOT NULL,
79
+ PRIMARY KEY (run_id, node, session_id),
80
+ FOREIGN KEY (run_id) REFERENCES relay_runs(id) ON DELETE CASCADE
81
+ );
82
+ `
83
+
84
+ func (p *RunProjection) migrate() error {
85
+ if _, err := p.DB.Exec(relayRunsSchema); err != nil {
86
+ return err
87
+ }
88
+ for name, definition := range map[string]string{
89
+ "retry_error": "TEXT",
90
+ "retry_attempt": "INTEGER",
91
+ "next_retry_at": "DATETIME",
92
+ } {
93
+ var count int
94
+ if err := p.DB.QueryRow(`SELECT COUNT(1) FROM pragma_table_info('relay_runs') WHERE name = ?`, name).Scan(&count); err != nil {
95
+ return err
96
+ }
97
+ if count == 0 {
98
+ if _, err := p.DB.Exec(`ALTER TABLE relay_runs ADD COLUMN ` + name + ` ` + definition); err != nil {
99
+ return err
100
+ }
101
+ }
102
+ }
103
+ return nil
104
+ }
105
+
106
+ var errRunNotFound = errors.New("run not found")
107
+ var errNodeRuntimeNotFound = errors.New("node runtime not found")
108
+
109
+ // IsNotFound reports a missing projection row.
110
+ func IsNotFound(err error) bool { return errors.Is(err, errRunNotFound) }
111
+
112
+ func (p *RunProjection) insertStart(ctx context.Context, s run.Start, now time.Time) error {
113
+ _, err := p.DB.ExecContext(ctx, `
114
+ INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
116
+ ON CONFLICT(id) DO NOTHING`,
117
+ string(s.ID), s.Repo, s.Workflow.Name, s.Ticket.ID, s.Ticket.Key,
118
+ string(run.StateStarting), now, now)
119
+ return err
120
+ }
121
+
122
+ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.State, lastErr string, finished *time.Time) error {
123
+ terminal := state == run.StateCompleted || state == run.StateCanceled
124
+ _, err := p.DB.ExecContext(ctx, `
125
+ UPDATE relay_runs SET state = ?, last_error = ?, updated_at = ?, finished_at = COALESCE(?, finished_at),
126
+ retry_error = CASE WHEN ? THEN NULL ELSE retry_error END,
127
+ retry_attempt = CASE WHEN ? THEN NULL ELSE retry_attempt END,
128
+ next_retry_at = CASE WHEN ? THEN NULL ELSE next_retry_at END
129
+ WHERE id = ?`,
130
+ string(state), lastErr, time.Now().UTC(), finished, terminal, terminal, terminal, string(id))
131
+ return err
132
+ }
133
+
134
+ func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
135
+ if status == nil {
136
+ _, err := p.DB.ExecContext(ctx, `
137
+ UPDATE relay_runs SET retry_error = NULL, retry_attempt = NULL, next_retry_at = NULL, updated_at = ?
138
+ WHERE id = ?`, time.Now().UTC(), string(id))
139
+ return err
140
+ }
141
+ _, err := p.DB.ExecContext(ctx, `
142
+ UPDATE relay_runs SET retry_error = ?, retry_attempt = ?, next_retry_at = ?, updated_at = ?
143
+ WHERE id = ?`, status.LastError, status.Attempt, status.NextRetryAt, time.Now().UTC(), string(id))
144
+ return err
145
+ }
146
+
147
+ func (p *RunProjection) updateNode(ctx context.Context, id run.ID, state run.State, node string, visit run.NodeVisitID) error {
148
+ p.runtimeMu.Lock()
149
+ defer p.runtimeMu.Unlock()
150
+ tx, err := p.DB.BeginTx(ctx, nil)
151
+ if err != nil {
152
+ return err
153
+ }
154
+ defer tx.Rollback()
155
+ now := time.Now().UTC()
156
+ if _, err := tx.ExecContext(ctx, `
157
+ UPDATE relay_runs SET state = ?, current_node = ?, current_node_visit_id = ?, updated_at = ?
158
+ WHERE id = ?`,
159
+ string(state), node, string(visit), now, string(id)); err != nil {
160
+ return err
161
+ }
162
+ // A revisit changes only the latest visit ID. Reusable terminal/session
163
+ // identities remain attached to this run/node row.
164
+ if _, err := tx.ExecContext(ctx, `
165
+ INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
166
+ VALUES (?, ?, ?, ?)
167
+ ON CONFLICT(run_id, node) DO UPDATE SET
168
+ node_visit_id = excluded.node_visit_id,
169
+ updated_at = excluded.updated_at`,
170
+ string(id), node, string(visit), now); err != nil {
171
+ return err
172
+ }
173
+ return tx.Commit()
174
+ }
175
+
176
+ func (p *RunProjection) getNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
177
+ var rt NodeRuntime
178
+ var terminalID, sessionID sql.NullString
179
+ err := p.DB.QueryRowContext(ctx, `
180
+ SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
181
+ FROM relay_node_runtime WHERE run_id = ? AND node = ?`, string(id), node).
182
+ Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt)
183
+ if errors.Is(err, sql.ErrNoRows) {
184
+ return NodeRuntime{}, errNodeRuntimeNotFound
185
+ }
186
+ if err != nil {
187
+ return NodeRuntime{}, err
188
+ }
189
+ rt.TerminalID = terminalID.String
190
+ rt.SessionID = sessionID.String
191
+ return rt, nil
192
+ }
193
+
194
+ func (p *RunProjection) updateNodeRuntime(ctx context.Context, rt NodeRuntime) error {
195
+ _, err := p.DB.ExecContext(ctx, `
196
+ INSERT INTO relay_node_runtime (run_id, node, terminal_id, session_id, node_visit_id, updated_at)
197
+ VALUES (?, ?, ?, ?, ?, ?)
198
+ ON CONFLICT(run_id, node) DO UPDATE SET
199
+ terminal_id = excluded.terminal_id,
200
+ session_id = excluded.session_id,
201
+ node_visit_id = excluded.node_visit_id,
202
+ updated_at = excluded.updated_at`,
203
+ string(rt.RunID), rt.Node, nullableString(rt.TerminalID), nullableString(rt.SessionID),
204
+ string(rt.NodeVisitID), time.Now().UTC())
205
+ return err
206
+ }
207
+
208
+ func (p *RunProjection) loadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
209
+ rt, err := p.getNodeRuntime(ctx, id, node)
210
+ if errors.Is(err, errNodeRuntimeNotFound) {
211
+ return NodeRuntime{RunID: id, Node: node}, nil
212
+ }
213
+ return rt, err
214
+ }
215
+
216
+ func (p *RunProjection) nodeRuntimeVisitIsCurrent(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) (bool, error) {
217
+ var count int
218
+ err := p.DB.QueryRowContext(ctx, `
219
+ SELECT COUNT(1) FROM relay_node_runtime
220
+ WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
221
+ string(id), node, string(visit)).Scan(&count)
222
+ return count == 1, err
223
+ }
224
+
225
+ func (p *RunProjection) replaceNodeRuntime(ctx context.Context, id run.ID, node string, visit run.NodeVisitID, terminalID, previousSessionID, sessionID string) error {
226
+ result, err := p.DB.ExecContext(ctx, `
227
+ UPDATE relay_node_runtime SET terminal_id = ?,
228
+ session_id = CASE WHEN COALESCE(session_id, '') = ? THEN ? ELSE session_id END,
229
+ updated_at = ?
230
+ WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
231
+ nullableString(terminalID), previousSessionID, nullableString(sessionID), time.Now().UTC(),
232
+ string(id), node, string(visit))
233
+ if err != nil {
234
+ return err
235
+ }
236
+ updated, err := result.RowsAffected()
237
+ if err != nil {
238
+ return err
239
+ }
240
+ if updated != 1 {
241
+ return fmt.Errorf("node runtime %s/%s visit %s is not current", id, node, visit)
242
+ }
243
+ return nil
244
+ }
245
+
246
+ func (p *RunProjection) updateNodeRuntimeVisit(ctx context.Context, id run.ID, node string, visit run.NodeVisitID) error {
247
+ p.runtimeMu.Lock()
248
+ defer p.runtimeMu.Unlock()
249
+ _, err := p.DB.ExecContext(ctx, `
250
+ INSERT INTO relay_node_runtime (run_id, node, node_visit_id, updated_at)
251
+ VALUES (?, ?, ?, ?)
252
+ ON CONFLICT(run_id, node) DO UPDATE SET
253
+ node_visit_id = excluded.node_visit_id,
254
+ updated_at = excluded.updated_at`,
255
+ string(id), node, string(visit), time.Now().UTC())
256
+ return err
257
+ }
258
+
259
+ func (p *RunProjection) registerNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (bool, error) {
260
+ p.runtimeMu.Lock()
261
+ defer p.runtimeMu.Unlock()
262
+ tx, err := p.DB.BeginTx(ctx, nil)
263
+ if err != nil {
264
+ return false, err
265
+ }
266
+ defer tx.Rollback()
267
+ var current run.NodeVisitID
268
+ if err := tx.QueryRowContext(ctx, `
269
+ SELECT node_visit_id FROM relay_node_runtime WHERE run_id = ? AND node = ?`,
270
+ string(registration.RunID), registration.Node).Scan(&current); err != nil {
271
+ if errors.Is(err, sql.ErrNoRows) {
272
+ return false, nil
273
+ }
274
+ return false, err
275
+ }
276
+ if _, err := tx.ExecContext(ctx, `
277
+ INSERT INTO relay_node_sessions (run_id, node, session_id, node_visit_id, created_at)
278
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT(run_id, node, session_id) DO NOTHING`,
279
+ string(registration.RunID), registration.Node, registration.SessionID, string(current), time.Now().UTC()); err != nil {
280
+ return false, err
281
+ }
282
+ var bound run.NodeVisitID
283
+ if err := tx.QueryRowContext(ctx, `
284
+ SELECT node_visit_id FROM relay_node_sessions
285
+ WHERE run_id = ? AND node = ? AND session_id = ?`,
286
+ string(registration.RunID), registration.Node, registration.SessionID).Scan(&bound); err != nil {
287
+ return false, err
288
+ }
289
+ if bound != current {
290
+ return false, tx.Commit()
291
+ }
292
+ result, err := tx.ExecContext(ctx, `
293
+ UPDATE relay_node_runtime SET session_id = ?, updated_at = ?
294
+ WHERE run_id = ? AND node = ? AND node_visit_id = ?`,
295
+ registration.SessionID, time.Now().UTC(), string(registration.RunID), registration.Node, string(bound))
296
+ if err != nil {
297
+ return false, err
298
+ }
299
+ updated, err := result.RowsAffected()
300
+ if err != nil {
301
+ return false, err
302
+ }
303
+ return updated == 1, tx.Commit()
304
+ }
305
+
306
+ func (p *RunProjection) hasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
307
+ var count int
308
+ err := p.DB.QueryRowContext(ctx, `
309
+ SELECT COUNT(1) FROM relay_processed_reports WHERE run_id = ? AND report_id = ?`,
310
+ string(id), reportID).Scan(&count)
311
+ return count == 1, err
312
+ }
313
+
314
+ func (p *RunProjection) recordProcessedReport(ctx context.Context, id run.ID, visit run.NodeVisitID, reportID string) error {
315
+ _, err := p.DB.ExecContext(ctx, `
316
+ INSERT INTO relay_processed_reports (run_id, report_id, node_visit_id, created_at)
317
+ VALUES (?, ?, ?, ?) ON CONFLICT(run_id, report_id) DO NOTHING`,
318
+ string(id), reportID, string(visit), time.Now().UTC())
319
+ return err
320
+ }
321
+
322
+ func (p *RunProjection) clearNodeRuntime(ctx context.Context, id run.ID, node string, clearTerminal, clearSession bool) error {
323
+ _, err := p.DB.ExecContext(ctx, `
324
+ UPDATE relay_node_runtime SET
325
+ terminal_id = CASE WHEN ? THEN NULL ELSE terminal_id END,
326
+ session_id = CASE WHEN ? THEN NULL ELSE session_id END,
327
+ updated_at = ?
328
+ WHERE run_id = ? AND node = ?`, clearTerminal, clearSession,
329
+ time.Now().UTC(), string(id), node)
330
+ return err
331
+ }
332
+
333
+ func (p *RunProjection) listNodeRuntimes(ctx context.Context, id run.ID) ([]NodeRuntime, error) {
334
+ rows, err := p.DB.QueryContext(ctx, `
335
+ SELECT run_id, node, terminal_id, session_id, node_visit_id, updated_at
336
+ FROM relay_node_runtime WHERE run_id = ?`, string(id))
337
+ if err != nil {
338
+ return nil, err
339
+ }
340
+ defer rows.Close()
341
+ var out []NodeRuntime
342
+ for rows.Next() {
343
+ var rt NodeRuntime
344
+ var terminalID, sessionID sql.NullString
345
+ if err := rows.Scan(&rt.RunID, &rt.Node, &terminalID, &sessionID, &rt.NodeVisitID, &rt.UpdatedAt); err != nil {
346
+ return nil, err
347
+ }
348
+ rt.TerminalID, rt.SessionID = terminalID.String, sessionID.String
349
+ out = append(out, rt)
350
+ }
351
+ return out, rows.Err()
352
+ }
353
+
354
+ func nullableString(value string) any {
355
+ if value == "" {
356
+ return nil
357
+ }
358
+ return value
359
+ }
360
+
361
+ func (p *RunProjection) get(ctx context.Context, id run.ID) (run.Run, error) {
362
+ row := p.DB.QueryRowContext(ctx, `
363
+ SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
364
+ retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
365
+ FROM relay_runs WHERE id = ?`, string(id))
366
+ return scanRun(row)
367
+ }
368
+
369
+ func (p *RunProjection) findByTicket(ctx context.Context, ticket string) (run.Run, error) {
370
+ row := p.DB.QueryRowContext(ctx, `
371
+ SELECT id, repo, workflow, ticket_id, ticket_key, state, current_node, current_node_visit_id, last_error,
372
+ retry_error, retry_attempt, next_retry_at, started_at, updated_at, finished_at
373
+ FROM relay_runs WHERE ticket_key = ? ORDER BY started_at DESC LIMIT 1`, ticket)
374
+ return scanRun(row)
375
+ }
376
+
377
+ type rowScanner interface {
378
+ Scan(dest ...any) error
379
+ }
380
+
381
+ func scanRun(row rowScanner) (run.Run, error) {
382
+ var r run.Run
383
+ var node, visit, lastErr, retryErr sql.NullString
384
+ var retryAttempt sql.NullInt64
385
+ var nextRetry, finished sql.NullTime
386
+ var started, updated time.Time
387
+ err := row.Scan(&r.ID, &r.Repo, &r.Workflow, &r.Ticket.ID, &r.Ticket.Key, &r.State,
388
+ &node, &visit, &lastErr, &retryErr, &retryAttempt, &nextRetry, &started, &updated, &finished)
389
+ if errors.Is(err, sql.ErrNoRows) {
390
+ return run.Run{}, errRunNotFound
391
+ }
392
+ if err != nil {
393
+ return run.Run{}, err
394
+ }
395
+ r.CurrentNode = node.String
396
+ r.CurrentNodeVisitID = run.NodeVisitID(visit.String)
397
+ r.LastError = lastErr.String
398
+ if retryErr.Valid && retryAttempt.Valid && nextRetry.Valid {
399
+ r.Retry = &run.RetryStatus{
400
+ Attempt: int(retryAttempt.Int64), LastError: retryErr.String, NextRetryAt: nextRetry.Time,
401
+ }
402
+ }
403
+ r.StartedAt = started
404
+ r.UpdatedAt = updated
405
+ if finished.Valid {
406
+ t := finished.Time
407
+ r.FinishedAt = &t
408
+ }
409
+ return r, nil
410
+ }
411
+
412
+ func (p *RunProjection) list(ctx context.Context, f run.Filter) ([]run.Run, error) {
413
+ q := `SELECT 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`
414
+ var args []any
415
+ if f.Repo != "" {
416
+ q += ` AND repo = ?`
417
+ args = append(args, f.Repo)
418
+ }
419
+ if f.Workflow != "" {
420
+ q += ` AND workflow = ?`
421
+ args = append(args, f.Workflow)
422
+ }
423
+ if f.Ticket != "" {
424
+ q += ` AND ticket_key = ?`
425
+ args = append(args, f.Ticket)
426
+ }
427
+ if f.Active != nil {
428
+ if *f.Active {
429
+ q += ` AND state NOT IN ('completed', 'canceled')`
430
+ } else {
431
+ q += ` AND state IN ('completed', 'canceled')`
432
+ }
433
+ }
434
+ q += ` ORDER BY started_at`
435
+ rows, err := p.DB.QueryContext(ctx, q, args...)
436
+ if err != nil {
437
+ return nil, err
438
+ }
439
+ defer rows.Close()
440
+ var out []run.Run
441
+ for rows.Next() {
442
+ r, err := scanRun(rows)
443
+ if err != nil {
444
+ return nil, err
445
+ }
446
+ out = append(out, r)
447
+ }
448
+ return out, rows.Err()
449
+ }
450
+
451
+ func (p *RunProjection) hasActive(ctx context.Context, column, value string) (bool, error) {
452
+ var n int
453
+ err := p.DB.QueryRowContext(ctx,
454
+ fmt.Sprintf(`SELECT COUNT(1) FROM relay_runs WHERE %s = ? AND state NOT IN ('completed', 'canceled')`, column),
455
+ value).Scan(&n)
456
+ return n > 0, err
457
+ }
458
+
459
+ // sweepRetention removes terminal projection rows whose finished_at is older
460
+ // than the retention window. Nonterminal runs are never removed.
461
+ func (p *RunProjection) sweepRetention(ctx context.Context, olderThan time.Time) ([]string, error) {
462
+ rows, err := p.DB.QueryContext(ctx, `
463
+ SELECT id FROM relay_runs
464
+ WHERE state IN ('completed', 'canceled') AND finished_at IS NOT NULL AND finished_at < ?`, olderThan)
465
+ if err != nil {
466
+ return nil, err
467
+ }
468
+ var ids []string
469
+ for rows.Next() {
470
+ var id string
471
+ if err := rows.Scan(&id); err != nil {
472
+ rows.Close()
473
+ return nil, err
474
+ }
475
+ ids = append(ids, id)
476
+ }
477
+ rows.Close()
478
+ for _, id := range ids {
479
+ tx, err := p.DB.BeginTx(ctx, nil)
480
+ if err != nil {
481
+ return ids, err
482
+ }
483
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_processed_reports WHERE run_id = ?`, id); err != nil {
484
+ tx.Rollback()
485
+ return ids, err
486
+ }
487
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_sessions WHERE run_id = ?`, id); err != nil {
488
+ tx.Rollback()
489
+ return ids, err
490
+ }
491
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_node_runtime WHERE run_id = ?`, id); err != nil {
492
+ tx.Rollback()
493
+ return ids, err
494
+ }
495
+ if _, err := tx.ExecContext(ctx, `DELETE FROM relay_runs WHERE id = ?`, id); err != nil {
496
+ tx.Rollback()
497
+ return ids, err
498
+ }
499
+ if err := tx.Commit(); err != nil {
500
+ return ids, err
501
+ }
502
+ }
503
+ return ids, nil
504
+ }