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,347 @@
1
+ package projection_test
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "path/filepath"
8
+ "sort"
9
+ "testing"
10
+ "time"
11
+
12
+ _ "modernc.org/sqlite"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
15
+ "github.com/rajpopat27/relay-flow/internal/run"
16
+ "github.com/rajpopat27/relay-flow/internal/task"
17
+ "github.com/rajpopat27/relay-flow/internal/workflow"
18
+ )
19
+
20
+ func openProjection(t *testing.T) (*projection.RunProjection, *sql.DB) {
21
+ t.Helper()
22
+ db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.db"))
23
+ if err != nil {
24
+ t.Fatalf("open projection database: %v", err)
25
+ }
26
+ db.SetMaxOpenConns(1)
27
+ t.Cleanup(func() { _ = db.Close() })
28
+ p := &projection.RunProjection{DB: db}
29
+ if err := p.Migrate(); err != nil {
30
+ t.Fatalf("migrate projection: %v", err)
31
+ }
32
+ return p, db
33
+ }
34
+
35
+ func projectionStart(id run.ID, ticket string) run.Start {
36
+ return run.Start{
37
+ ID: id,
38
+ Repo: "payments",
39
+ RepoPath: "/srv/payments",
40
+ Workflow: workflow.Workflow{
41
+ Name: "basicFlow",
42
+ Repos: []string{"payments"},
43
+ Nodes: map[string]workflow.Node{
44
+ "start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
45
+ "coding": {
46
+ Type: workflow.NodeAgent, Agent: "build", Description: "work",
47
+ OnSuccess: []workflow.Route{{Target: "end"}},
48
+ OnFailure: []workflow.Route{{Target: "coding"}},
49
+ },
50
+ "end": {},
51
+ },
52
+ },
53
+ Ticket: task.TicketRef{ID: ticket, Key: ticket, Title: "parent"},
54
+ }
55
+ }
56
+
57
+ func TestSharedProjectionSchemaAndQueries(t *testing.T) {
58
+ ctx := context.Background()
59
+ p, db := openProjection(t)
60
+
61
+ rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'relay_%'`)
62
+ if err != nil {
63
+ t.Fatalf("list relay tables: %v", err)
64
+ }
65
+ defer rows.Close()
66
+ var tables []string
67
+ for rows.Next() {
68
+ var name string
69
+ if err := rows.Scan(&name); err != nil {
70
+ t.Fatalf("scan relay table: %v", err)
71
+ }
72
+ tables = append(tables, name)
73
+ }
74
+ if err := rows.Err(); err != nil {
75
+ t.Fatalf("iterate relay tables: %v", err)
76
+ }
77
+ sort.Strings(tables)
78
+ wantTables := []string{
79
+ "relay_executor_identity",
80
+ "relay_node_runtime",
81
+ "relay_node_sessions",
82
+ "relay_processed_reports",
83
+ "relay_runs",
84
+ }
85
+ if len(tables) != len(wantTables) {
86
+ t.Fatalf("relay tables = %v, want exactly %v", tables, wantTables)
87
+ }
88
+ for i := range wantTables {
89
+ if tables[i] != wantTables[i] {
90
+ t.Fatalf("relay tables = %v, want exactly %v", tables, wantTables)
91
+ }
92
+ }
93
+
94
+ start := projectionStart("payments/basicFlow/PAY-101", "PAY-101")
95
+ startedAt := time.Unix(100, 0).UTC()
96
+ if err := p.InsertStart(ctx, start, startedAt); err != nil {
97
+ t.Fatalf("insert run: %v", err)
98
+ }
99
+ got, err := p.Get(ctx, start.ID)
100
+ if err != nil {
101
+ t.Fatalf("get run: %v", err)
102
+ }
103
+ if got.ID != start.ID || got.Ticket.Key != "PAY-101" || got.State != run.StateStarting {
104
+ t.Fatalf("get run = %+v", got)
105
+ }
106
+ byTicket, err := p.FindByTicket(ctx, "PAY-101")
107
+ if err != nil || byTicket.ID != start.ID {
108
+ t.Fatalf("find by ticket = %+v, err %v", byTicket, err)
109
+ }
110
+ listed, err := p.List(ctx, run.Filter{Workflow: "basicFlow", Active: boolPtr(true)})
111
+ if err != nil || len(listed) != 1 || listed[0].ID != start.ID {
112
+ t.Fatalf("list active runs = %+v, err %v", listed, err)
113
+ }
114
+ activeWorkflow, err := p.HasActiveWorkflow(ctx, "basicFlow")
115
+ if err != nil || !activeWorkflow {
116
+ t.Fatalf("HasActiveWorkflow = %v, err %v", activeWorkflow, err)
117
+ }
118
+ activeRepo, err := p.HasActiveRepo(ctx, "payments")
119
+ if err != nil || !activeRepo {
120
+ t.Fatalf("HasActiveRepo = %v, err %v", activeRepo, err)
121
+ }
122
+
123
+ if err := p.UpdateNode(ctx, start.ID, run.StateWaiting, "coding", "visit-1"); err != nil {
124
+ t.Fatalf("update node: %v", err)
125
+ }
126
+ runtime := projection.NodeRuntime{
127
+ RunID: start.ID, Node: "coding", TerminalID: "term-1", SessionID: "session-1",
128
+ NodeVisitID: "visit-1", UpdatedAt: startedAt,
129
+ }
130
+ if err := p.UpdateNodeRuntime(ctx, runtime); err != nil {
131
+ t.Fatalf("update node runtime: %v", err)
132
+ }
133
+ gotRuntime, err := p.GetNodeRuntime(ctx, start.ID, "coding")
134
+ if err != nil || gotRuntime.TerminalID != "term-1" || gotRuntime.SessionID != "session-1" || gotRuntime.NodeVisitID != "visit-1" {
135
+ t.Fatalf("get node runtime = %+v, err %v", gotRuntime, err)
136
+ }
137
+ if err := p.RecordProcessedReport(ctx, start.ID, "visit-1", "report-1"); err != nil {
138
+ t.Fatalf("record report: %v", err)
139
+ }
140
+ if err := p.RecordProcessedReport(ctx, start.ID, "visit-1", "report-1"); err != nil {
141
+ t.Fatalf("idempotent record report: %v", err)
142
+ }
143
+ processed, err := p.HasProcessedReport(ctx, start.ID, "report-1")
144
+ if err != nil || !processed {
145
+ t.Fatalf("HasProcessedReport = %v, err %v", processed, err)
146
+ }
147
+ var reportCount int
148
+ if err := db.QueryRow(`SELECT COUNT(*) FROM relay_processed_reports WHERE run_id = ?`, string(start.ID)).Scan(&reportCount); err != nil {
149
+ t.Fatalf("count processed reports: %v", err)
150
+ }
151
+ if reportCount != 1 {
152
+ t.Fatalf("processed report rows = %d, want one after repeated write", reportCount)
153
+ }
154
+ }
155
+
156
+ func TestProjectionWritesAreDerivedAndDoNotSelectRoutes(t *testing.T) {
157
+ ctx := context.Background()
158
+ p, db := openProjection(t)
159
+ start := projectionStart("run-derived", "PAY-201")
160
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
161
+ t.Fatal(err)
162
+ }
163
+ if err := p.UpdateNode(ctx, start.ID, run.StateWaiting, "coding", "visit-1"); err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if err := p.UpdateNode(ctx, start.ID, run.StateWaiting, "coding", "visit-1"); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ if err := p.RecordProcessedReport(ctx, start.ID, "visit-1", "report-1"); err != nil {
170
+ t.Fatal(err)
171
+ }
172
+ if err := p.RecordProcessedReport(ctx, start.ID, "visit-1", "report-1"); err != nil {
173
+ t.Fatal(err)
174
+ }
175
+
176
+ columns, err := db.Query(`SELECT name FROM pragma_table_info('relay_runs')`)
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ defer columns.Close()
181
+ for columns.Next() {
182
+ var name string
183
+ if err := columns.Scan(&name); err != nil {
184
+ t.Fatal(err)
185
+ }
186
+ switch name {
187
+ case "selected_route", "report", "report_id", "next_step":
188
+ t.Fatalf("projection stores execution-authority column %q", name)
189
+ }
190
+ }
191
+ var receipts int
192
+ if err := db.QueryRow(`SELECT COUNT(*) FROM relay_processed_reports WHERE run_id = ?`, string(start.ID)).Scan(&receipts); err != nil {
193
+ t.Fatal(err)
194
+ }
195
+ if receipts != 1 {
196
+ t.Fatalf("idempotent projection receipt count = %d, want one", receipts)
197
+ }
198
+ got, err := p.Get(ctx, start.ID)
199
+ if err != nil || got.CurrentNode != "coding" || got.CurrentNodeVisitID != "visit-1" {
200
+ t.Fatalf("derived run after repeated writes = %+v, err %v", got, err)
201
+ }
202
+ }
203
+
204
+ func TestProjectionImplementationIsSharedByExecutorModes(t *testing.T) {
205
+ for _, mode := range []string{"goworkflows", "temporal"} {
206
+ t.Run(mode, func(t *testing.T) {
207
+ p, db := openProjection(t)
208
+ var count int
209
+ if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'relay_%'`).Scan(&count); err != nil {
210
+ t.Fatal(err)
211
+ }
212
+ if count != 5 {
213
+ t.Fatalf("relay schema table count = %d, want five shared tables", count)
214
+ }
215
+ if err := p.InsertStart(context.Background(), projectionStart(run.ID("run-"+mode), "PAY-"+mode), time.Now().UTC()); err != nil {
216
+ t.Fatalf("insert %s run through shared projection: %v", mode, err)
217
+ }
218
+ })
219
+ }
220
+ }
221
+
222
+ func TestInitDatabaseWithIdentityWritesMarkerBeforeSuccess(t *testing.T) {
223
+ path := filepath.Join(t.TempDir(), "state.db")
224
+ identity := projection.ExecutorIdentity{
225
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "relay-flow-init",
226
+ }
227
+ if err := projection.InitDatabaseWithIdentity(path, identity); err != nil {
228
+ t.Fatalf("initialize projection with identity: %v", err)
229
+ }
230
+ db, err := sql.Open("sqlite", path)
231
+ if err != nil {
232
+ t.Fatal(err)
233
+ }
234
+ defer db.Close()
235
+ p := &projection.RunProjection{DB: db}
236
+ got, ok, err := p.Identity(context.Background())
237
+ if err != nil || !ok || got != identity {
238
+ t.Fatalf("initialized identity = %#v/%v, err %v", got, ok, err)
239
+ }
240
+ }
241
+
242
+ func TestExecutorIdentityIsSingletonAndImmutable(t *testing.T) {
243
+ ctx := context.Background()
244
+ p, db := openProjection(t)
245
+ embedded := projection.ExecutorIdentity{ExecutorPlugin: "goworkflows"}
246
+ if err := p.InitializeIdentity(ctx, embedded); err != nil {
247
+ t.Fatalf("initialize embedded identity: %v", err)
248
+ }
249
+ if err := p.InitializeIdentity(ctx, embedded); err != nil {
250
+ t.Fatalf("repeat identical identity initialization: %v", err)
251
+ }
252
+ got, ok, err := p.Identity(ctx)
253
+ if err != nil || !ok || got != embedded {
254
+ t.Fatalf("identity = %#v/%v, err %v; want %#v/present", got, ok, err, embedded)
255
+ }
256
+ if err := p.VerifyIdentity(ctx, embedded); err != nil {
257
+ t.Fatalf("verify matching identity: %v", err)
258
+ }
259
+
260
+ for _, tc := range []struct {
261
+ name string
262
+ expected projection.ExecutorIdentity
263
+ wantError error
264
+ }{
265
+ {
266
+ name: "different executor",
267
+ expected: projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "relay-flow"},
268
+ wantError: projection.ErrIdentityMismatch,
269
+ },
270
+ {
271
+ name: "Temporal fields in embedded config",
272
+ expected: projection.ExecutorIdentity{ExecutorPlugin: "goworkflows", TemporalAddress: "localhost:7233"},
273
+ wantError: nil,
274
+ },
275
+ } {
276
+ t.Run("reject "+tc.name, func(t *testing.T) {
277
+ err := p.VerifyIdentity(ctx, tc.expected)
278
+ if tc.wantError != nil {
279
+ if !errors.Is(err, tc.wantError) {
280
+ t.Fatalf("verify mismatch error = %v, want %v", err, tc.wantError)
281
+ }
282
+ return
283
+ }
284
+ if err == nil {
285
+ t.Fatal("invalid embedded identity unexpectedly verified")
286
+ }
287
+ })
288
+ }
289
+ var identityRows int
290
+ if err := db.QueryRow(`SELECT COUNT(*) FROM relay_executor_identity`).Scan(&identityRows); err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ if identityRows != 1 {
294
+ t.Fatalf("identity rows = %d, want singleton", identityRows)
295
+ }
296
+ }
297
+
298
+ func TestLegacyMissingIdentityIsGoworkflowsOnly(t *testing.T) {
299
+ ctx := context.Background()
300
+ p, _ := openProjection(t)
301
+ legacy := projection.ExecutorIdentity{ExecutorPlugin: "goworkflows"}
302
+ if err := p.VerifyIdentity(ctx, legacy); err != nil {
303
+ t.Fatalf("legacy embedded identity should be adopted: %v", err)
304
+ }
305
+ got, ok, err := p.Identity(ctx)
306
+ if err != nil || !ok || got != legacy {
307
+ t.Fatalf("adopted legacy identity = %#v/%v, err %v", got, ok, err)
308
+ }
309
+
310
+ p2, _ := openProjection(t)
311
+ temporal := projection.ExecutorIdentity{
312
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "relay-flow-test",
313
+ }
314
+ if err := p2.VerifyIdentity(ctx, temporal); !errors.Is(err, projection.ErrIdentityMissing) {
315
+ t.Fatalf("missing Temporal identity error = %v, want ErrIdentityMissing", err)
316
+ }
317
+ }
318
+
319
+ func TestIdentitySurvivesProjectionRebuild(t *testing.T) {
320
+ ctx := context.Background()
321
+ p, db := openProjection(t)
322
+ temporal := projection.ExecutorIdentity{
323
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "relay-flow-rebuild",
324
+ }
325
+ if err := p.InitializeIdentity(ctx, temporal); err != nil {
326
+ t.Fatalf("initialize Temporal identity: %v", err)
327
+ }
328
+ start := projectionStart("run-1", "PAY-101")
329
+ if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
330
+ t.Fatalf("insert run before rebuild: %v", err)
331
+ }
332
+ if _, err := db.Exec(`DELETE FROM relay_runs`); err != nil {
333
+ t.Fatalf("clear derived projection: %v", err)
334
+ }
335
+ if err := p.Migrate(); err != nil {
336
+ t.Fatalf("rebuild shared projection schema: %v", err)
337
+ }
338
+ if err := p.VerifyIdentity(ctx, temporal); err != nil {
339
+ t.Fatalf("identity after projection rebuild: %v", err)
340
+ }
341
+ got, ok, err := p.Identity(ctx)
342
+ if err != nil || !ok || got != temporal {
343
+ t.Fatalf("identity after rebuild = %#v/%v, err %v", got, ok, err)
344
+ }
345
+ }
346
+
347
+ func boolPtr(v bool) *bool { return &v }