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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- 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(¤t); 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
|
+
}
|