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