relay-flow 0.2.4-alpha → 0.2.6-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 (60) hide show
  1. package/README.md +29 -18
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/beads_composition_test.go +3 -3
  4. package/cmd/relay-flow/main.go +94 -13
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +98 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -0
  12. package/examples/config-reference.yaml +7 -3
  13. package/examples/minimal-beads-task-workflow.yaml +2 -1
  14. package/examples/workflow-reference.yaml +2 -1
  15. package/go.mod +37 -16
  16. package/go.sum +129 -61
  17. package/internal/config/machine.go +33 -1
  18. package/internal/config/machine_test.go +76 -0
  19. package/internal/execution/goworkflows/activities.go +19 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/engine_test.go +1 -1
  22. package/internal/execution/goworkflows/node_runtime_test.go +113 -4
  23. package/internal/execution/goworkflows/projection.go +47 -464
  24. package/internal/execution/projection/projection.go +867 -0
  25. package/internal/execution/projection/projection_test.go +347 -0
  26. package/internal/execution/temporal/activities.go +586 -0
  27. package/internal/execution/temporal/engine.go +384 -0
  28. package/internal/execution/temporal/engine_test.go +277 -0
  29. package/internal/execution/temporal/interpreter.go +736 -0
  30. package/internal/execution/temporal/operations.go +455 -0
  31. package/internal/execution/temporal/operations_test.go +101 -0
  32. package/internal/execution/temporal/recovery.go +194 -0
  33. package/internal/execution/temporal/recovery_runtime.go +41 -0
  34. package/internal/execution/temporal/recovery_test.go +102 -0
  35. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  36. package/internal/execution/temporal/spike_test.go +934 -0
  37. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  38. package/internal/harness/harness.go +5 -0
  39. package/internal/harness/opencode/opencode.go +14 -3
  40. package/internal/harness/opencode/opencode_test.go +1 -1
  41. package/internal/harness/opencode/repo_setup.go +1 -1
  42. package/internal/harness/opencode/task_env_test.go +57 -0
  43. package/internal/harness/pi/pi.go +58 -47
  44. package/internal/harness/pi/pi_test.go +26 -10
  45. package/internal/harness/pi/prompt_test.go +30 -1
  46. package/internal/harness/pi/task_env_test.go +51 -0
  47. package/internal/harness/pi/validation_test.go +27 -51
  48. package/internal/repo/service.go +18 -8
  49. package/internal/runner/herdr/herdr.go +14 -0
  50. package/internal/runner/herdr/herdr_test.go +20 -0
  51. package/internal/runner/orca/orca.go +33 -0
  52. package/internal/runner/orca/orca_test.go +33 -4
  53. package/internal/runner/runner.go +8 -0
  54. package/internal/task/beads/agent_env_test.go +52 -0
  55. package/internal/task/beads/beads.go +87 -7
  56. package/internal/task/beads/beads_test.go +78 -9
  57. package/internal/task/beads/repo_composition_test.go +47 -3
  58. package/internal/task/factory.go +31 -2
  59. package/internal/task/task.go +10 -0
  60. package/package.json +1 -1
@@ -0,0 +1,733 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "reflect"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/config"
15
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
16
+ temporalexec "github.com/rajpopat27/relay-flow/internal/execution/temporal"
17
+ "github.com/rajpopat27/relay-flow/internal/identity"
18
+ "github.com/rajpopat27/relay-flow/internal/paths"
19
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
20
+ "github.com/rajpopat27/relay-flow/internal/runner"
21
+ "github.com/rajpopat27/relay-flow/internal/server"
22
+ "github.com/rajpopat27/relay-flow/internal/task"
23
+ "github.com/rajpopat27/relay-flow/internal/workflow"
24
+ enumspb "go.temporal.io/api/enums/v1"
25
+ workflowservice "go.temporal.io/api/workflowservice/v1"
26
+ "go.temporal.io/sdk/client"
27
+ temporalworker "go.temporal.io/sdk/worker"
28
+ temporalworkflow "go.temporal.io/sdk/workflow"
29
+ _ "modernc.org/sqlite"
30
+ )
31
+
32
+ type temporalScenarioFixture struct {
33
+ tasks *scenarioTaskSystem
34
+ runner *scenarioRunner
35
+ log *scenarioLog
36
+ path string
37
+ namespace string
38
+ paths paths.Paths
39
+ cli *server.Client
40
+ ctx context.Context
41
+ stop context.CancelFunc
42
+ done chan error
43
+ }
44
+
45
+ func newTemporalScenarioFixture(t *testing.T) *temporalScenarioFixture {
46
+ t.Helper()
47
+ log := newScenarioLog()
48
+ tasks := newScenarioTaskSystem(log)
49
+ rnr := newScenarioRunner(log)
50
+ setScenarioFactoryAdapters(tasks, rnr, newScenarioHarness(log))
51
+ root := filepath.Join(t.TempDir(), ".relay-flow")
52
+ t.Setenv("RELAY_FLOW_HOME", root)
53
+ p := pathsForRoot(root)
54
+ namespace := fmt.Sprintf("relay-flow-report-%d", time.Now().UnixNano())
55
+ if err := ensureTemporalNamespace(context.Background(), "localhost:7233", namespace, 30); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ time.Sleep(5 * time.Second)
59
+ if err := paths.Ensure(p); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ if err := config.SaveMachine(p.Config, &config.Machine{
63
+ PollIntervalSeconds: 1,
64
+ CompletedRunRetentionDays: 30,
65
+ TaskPlugin: scenarioTaskPlugin,
66
+ RunnerPlugin: scenarioRunnerPlugin,
67
+ HarnessPlugin: scenarioHarnessPlugin,
68
+ ExecutorPlugin: "temporal",
69
+ TemporalAddress: "localhost:7233",
70
+ TemporalNamespace: namespace,
71
+ Repos: map[string]config.Repo{
72
+ scenarioRepo: {Path: scenarioRepoPath},
73
+ },
74
+ }); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ if err := (&workflow.Store{Dir: p.Workflows}).Put(scenarioWorkflowName, scenarioWorkflowYAML); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ cfg, err := config.LoadMachine(p.Config)
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if err := projection.InitDatabaseWithIdentity(p.Database, projection.ExecutorIdentity{
85
+ ExecutorPlugin: "temporal", TemporalAddress: cfg.TemporalAddress, TemporalNamespace: cfg.TemporalNamespace,
86
+ }); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ ctx, stop := context.WithCancel(context.Background())
90
+ fixture := &temporalScenarioFixture{
91
+ tasks: tasks, runner: rnr, log: log, path: p.Database, namespace: namespace, paths: p, cli: server.NewClient(p.Socket),
92
+ ctx: ctx, stop: stop, done: make(chan error, 1),
93
+ }
94
+ go func() { fixture.done <- serveRoot(ctx, p, false) }()
95
+ waitForServerOrError(t, fixture.cli, fixture.done)
96
+ assertNoEmbeddedExecutionTables(t, p.Database)
97
+ return fixture
98
+ }
99
+
100
+ func (f *temporalScenarioFixture) close(t *testing.T) {
101
+ t.Helper()
102
+ if err := f.cli.Stop(context.Background()); err != nil {
103
+ t.Fatal(err)
104
+ }
105
+ select {
106
+ case err := <-f.done:
107
+ if err != nil {
108
+ t.Fatalf("serveRoot: %v", err)
109
+ }
110
+ case <-time.After(15 * time.Second):
111
+ f.stop()
112
+ t.Fatal("Temporal serve fixture did not stop")
113
+ }
114
+ }
115
+
116
+ func TestTemporalCompositionServesCommonWorkflowAndRunQueries(t *testing.T) {
117
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
118
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run common Temporal service API coverage")
119
+ }
120
+ fixture := newTemporalScenarioFixture(t)
121
+ defer fixture.close(t)
122
+ waitScenario(t, 20*time.Second, func() bool {
123
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
124
+ return err == nil && run.State == runsvc.StateWaiting && run.CurrentNode == "implement"
125
+ })
126
+ if repos, err := fixture.cli.ListRepos(context.Background()); err != nil || len(repos) != 1 || repos[0].Name != scenarioRepo {
127
+ t.Fatalf("ListRepos = %#v, %v", repos, err)
128
+ }
129
+ if workflows, err := fixture.cli.ListWorkflows(context.Background()); err != nil || len(workflows) != 1 || workflows[0].Name != scenarioWorkflowName {
130
+ t.Fatalf("ListWorkflows = %#v, %v", workflows, err)
131
+ }
132
+ if got, err := fixture.cli.GetWorkflow(context.Background(), scenarioWorkflowName); err != nil || got.Name != scenarioWorkflowName {
133
+ t.Fatalf("GetWorkflow = %#v, %v", got, err)
134
+ }
135
+ if runs, err := fixture.cli.ListRuns(context.Background(), runsvc.Filter{Active: boolPointer(true)}); err != nil || len(runs) != 1 || runs[0].CurrentNode != "implement" {
136
+ t.Fatalf("ListRuns = %#v, %v", runs, err)
137
+ }
138
+ }
139
+
140
+ func boolPointer(value bool) *bool { return &value }
141
+
142
+ func TestTemporalReportAckSurvivesMissingProjectionReceipt(t *testing.T) {
143
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
144
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal report delivery against the local server")
145
+ }
146
+ fixture := newTemporalScenarioFixture(t)
147
+ defer fixture.close(t)
148
+ temporalClient, err := client.Dial(client.Options{HostPort: "localhost:7233", Namespace: fixture.namespace})
149
+ if err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ defer temporalClient.Close()
153
+
154
+ var current runsvc.Run
155
+ waitScenario(t, 20*time.Second, func() bool {
156
+ var err error
157
+ current, err = fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
158
+ return err == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "implement"
159
+ })
160
+ var beforeState temporalexec.RunStateSnapshot
161
+ encoded, err := temporalClient.QueryWorkflow(context.Background(), string(current.ID), "", "relay-flow/run-state-v1")
162
+ if err != nil || encoded.Get(&beforeState) != nil || beforeState.Run.State != runsvc.StateWaiting || beforeState.Run.CurrentNode != "implement" || beforeState.Run.CurrentNodeVisitID == "" {
163
+ t.Fatalf("initial run-state query = %#v, %v", beforeState, err)
164
+ }
165
+ var beforeReport temporalexec.ReportStateSnapshot
166
+ encoded, err = temporalClient.QueryWorkflow(context.Background(), string(current.ID), "", "relay-flow/report-state-v1", temporalexec.ReportStateQuery{ReportID: "temporal-report-1"})
167
+ if err != nil || encoded.Get(&beforeReport) != nil || beforeReport.Processed || beforeReport.CurrentNodeVisitID != beforeState.Run.CurrentNodeVisitID {
168
+ t.Fatalf("initial report-state query = %#v, %v", beforeReport, err)
169
+ }
170
+ report := runsvc.ReportRequest{
171
+ RunID: current.ID, Node: current.CurrentNode, ReportID: "temporal-report-1",
172
+ Report: scenarioReport(workflow.OutcomeSuccess, "verify"),
173
+ }
174
+ ack, err := fixture.cli.SubmitReport(context.Background(), report)
175
+ if err != nil || !ack.Accepted || ack.Duplicate {
176
+ t.Fatalf("first Temporal report ack = %+v, err %v", ack, err)
177
+ }
178
+ waitScenario(t, 20*time.Second, func() bool {
179
+ r, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
180
+ return err == nil && r.CurrentNode == "verify" && r.State == runsvc.StateWaiting
181
+ })
182
+ var afterReport temporalexec.ReportStateSnapshot
183
+ encoded, err = temporalClient.QueryWorkflow(context.Background(), string(current.ID), "", "relay-flow/report-state-v1", temporalexec.ReportStateQuery{ReportID: report.ReportID})
184
+ if err != nil || encoded.Get(&afterReport) != nil || !afterReport.Processed || afterReport.CurrentNode != "verify" || afterReport.State != runsvc.StateWaiting {
185
+ t.Fatalf("consumed report-state query = %#v, %v", afterReport, err)
186
+ }
187
+
188
+ db, err := sql.Open("sqlite", fixture.path)
189
+ if err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ if _, err := db.Exec(`DELETE FROM relay_processed_reports WHERE run_id = ? AND report_id = ?`, string(report.RunID), report.ReportID); err != nil {
193
+ db.Close()
194
+ t.Fatal(err)
195
+ }
196
+ if err := db.Close(); err != nil {
197
+ t.Fatal(err)
198
+ }
199
+ duplicate, err := fixture.cli.SubmitReport(context.Background(), report)
200
+ if err != nil || !duplicate.Accepted || !duplicate.Duplicate {
201
+ t.Fatalf("replayed report without projection receipt = %+v, err %v", duplicate, err)
202
+ }
203
+ if got := fixture.tasks.commentCount("implement", "summary"); got != 1 {
204
+ t.Fatalf("Temporal duplicate repeated summary effects: %d", got)
205
+ }
206
+ if got := fixture.tasks.commentCount("verify", "feedback"); got != 1 {
207
+ t.Fatalf("Temporal duplicate repeated feedback effects: %d", got)
208
+ }
209
+ }
210
+
211
+ func TestTemporalReportAndCancelRaceIsReconciled(t *testing.T) {
212
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
213
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run the Temporal report/cancel race against the local server")
214
+ }
215
+ fixture := newTemporalScenarioFixture(t)
216
+ defer fixture.close(t)
217
+ var current runsvc.Run
218
+ waitScenario(t, 20*time.Second, func() bool {
219
+ var err error
220
+ current, err = fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
221
+ return err == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "implement"
222
+ })
223
+ report := runsvc.ReportRequest{RunID: current.ID, Node: current.CurrentNode, ReportID: "race-report", Report: scenarioReport(workflow.OutcomeSuccess, "verify")}
224
+ results := make(chan error, 2)
225
+ go func() {
226
+ _, err := fixture.cli.SubmitReport(context.Background(), report)
227
+ results <- err
228
+ }()
229
+ go func() { results <- fixture.cli.CancelRun(context.Background(), scenarioTicket, "race cancellation") }()
230
+ for i := 0; i < 2; i++ {
231
+ if err := <-results; err != nil {
232
+ t.Fatalf("report/cancel race returned error: %v", err)
233
+ }
234
+ }
235
+ waitScenario(t, 30*time.Second, func() bool {
236
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
237
+ return err == nil && (run.State == runsvc.StateCanceled || run.State == runsvc.StateCompleted)
238
+ })
239
+ }
240
+
241
+ func TestTemporalCancellationClosesTerminalsAndPreservesWorkspace(t *testing.T) {
242
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
243
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal cancellation against the local server")
244
+ }
245
+ fixture := newTemporalScenarioFixture(t)
246
+ defer fixture.close(t)
247
+ waitScenario(t, 20*time.Second, func() bool {
248
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
249
+ return err == nil && run.State == runsvc.StateWaiting && run.CurrentNode == "implement"
250
+ })
251
+ if err := fixture.cli.CancelRun(context.Background(), scenarioTicket, "cancellation test"); err != nil {
252
+ t.Fatal(err)
253
+ }
254
+ waitScenario(t, 30*time.Second, func() bool {
255
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
256
+ return err == nil && run.State == runsvc.StateCanceled
257
+ })
258
+ runnerState := snapshotScenarioRunnerState(fixture.runner)
259
+ if len(runnerState.environments) != 1 {
260
+ t.Fatalf("cancellation removed workspace: %+v", runnerState)
261
+ }
262
+ for title, terminal := range runnerState.terminals {
263
+ if terminal.live {
264
+ t.Fatalf("cancellation left terminal %q live", title)
265
+ }
266
+ }
267
+ if got := fixture.tasks.commentCount("parent", "cancellation"); got != 1 {
268
+ t.Fatalf("cancellation comments = %d, want one", got)
269
+ }
270
+ fixture.tasks.mu.Lock()
271
+ var cancellationBody string
272
+ for _, comment := range fixture.tasks.comments {
273
+ if comment.kind == "cancellation" {
274
+ cancellationBody = comment.body
275
+ }
276
+ }
277
+ fixture.tasks.mu.Unlock()
278
+ if cancellationBody != "Run canceled: cancellation test" {
279
+ t.Fatalf("cancellation reason = %q", cancellationBody)
280
+ }
281
+ }
282
+
283
+ func TestTemporalRestartFencesStaleReport(t *testing.T) {
284
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
285
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal restart fencing against the local server")
286
+ }
287
+ fixture := newTemporalScenarioFixture(t)
288
+ defer fixture.close(t)
289
+ var oldRun runsvc.Run
290
+ waitScenario(t, 20*time.Second, func() bool {
291
+ var err error
292
+ oldRun, err = fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
293
+ return err == nil && oldRun.State == runsvc.StateWaiting && oldRun.CurrentNode == "implement"
294
+ })
295
+ if err := fixture.cli.CancelRun(context.Background(), scenarioTicket, "restart fencing"); err != nil {
296
+ t.Fatal(err)
297
+ }
298
+ waitScenario(t, 20*time.Second, func() bool {
299
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
300
+ return err == nil && run.ID == oldRun.ID && run.State == runsvc.StateCanceled
301
+ })
302
+ newRun, err := fixture.cli.RestartRun(context.Background(), scenarioTicket)
303
+ if err != nil {
304
+ t.Fatal(err)
305
+ }
306
+ if newRun.ID == oldRun.ID || newRun.AttemptID <= oldRun.AttemptID {
307
+ t.Fatalf("restart reused old attempt: old=%+v new=%+v", oldRun, newRun)
308
+ }
309
+ waitScenario(t, 20*time.Second, func() bool {
310
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
311
+ return err == nil && run.ID == newRun.ID && run.State == runsvc.StateWaiting && run.CurrentNode == "implement"
312
+ })
313
+ stale, err := fixture.cli.SubmitReport(context.Background(), runsvc.ReportRequest{
314
+ RunID: oldRun.ID, Node: oldRun.CurrentNode, ReportID: "stale-attempt-report",
315
+ Report: scenarioReport(workflow.OutcomeSuccess, "verify"),
316
+ })
317
+ if err != nil || !stale.Accepted || !stale.Duplicate {
318
+ t.Fatalf("stale report ack = %+v, err %v", stale, err)
319
+ }
320
+ current, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
321
+ if err != nil {
322
+ t.Fatal(err)
323
+ }
324
+ if current.ID != newRun.ID || current.CurrentNode != "implement" || current.State != runsvc.StateWaiting {
325
+ t.Fatalf("stale report affected new attempt: %+v", current)
326
+ }
327
+ staleClient, err := client.Dial(client.Options{HostPort: "localhost:7233", Namespace: fixture.namespace})
328
+ if err != nil {
329
+ t.Fatal(err)
330
+ }
331
+ // A reconcile request addressed to the old, canceled execution is allowed
332
+ // to be rejected by Temporal, but must never be redirected to the new ID.
333
+ _ = staleClient.SignalWorkflow(context.Background(), string(oldRun.ID), "", "reconcile", struct{}{})
334
+ staleClient.Close()
335
+ current, err = fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
336
+ if err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ if current.ID != newRun.ID || current.CurrentNode != "implement" || current.State != runsvc.StateWaiting {
340
+ t.Fatalf("stale reconcile affected new attempt: %+v", current)
341
+ }
342
+ }
343
+
344
+ func TestTemporalProjectionRecoveryLeavesMailboxesUntouched(t *testing.T) {
345
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
346
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal recovery against the local server")
347
+ }
348
+ fixture := newTemporalScenarioFixture(t)
349
+ var current runsvc.Run
350
+ waitScenario(t, 20*time.Second, func() bool {
351
+ var err error
352
+ current, err = fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
353
+ return err == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "implement"
354
+ })
355
+ before := snapshotScenarioTaskState(fixture.tasks)
356
+ beforeRunner := snapshotScenarioRunnerState(fixture.runner)
357
+ fixture.close(t)
358
+
359
+ ctx, cancel := context.WithCancel(context.Background())
360
+ defer cancel()
361
+ done := make(chan error, 1)
362
+ go func() { done <- serveRoot(ctx, fixture.paths, true) }()
363
+ recoveryClient := server.NewClient(fixture.paths.Socket)
364
+ waitForServerOrError(t, recoveryClient, done)
365
+ assertNoEmbeddedExecutionTables(t, fixture.paths.Database)
366
+ backups, err := filepath.Glob(fixture.paths.Database + ".recover-*.bak")
367
+ if err != nil || len(backups) == 0 {
368
+ t.Fatalf("Temporal recovery backup files = %v, err %v", backups, err)
369
+ }
370
+ after := snapshotScenarioTaskState(fixture.tasks)
371
+ afterRunner := snapshotScenarioRunnerState(fixture.runner)
372
+ if !reflect.DeepEqual(before, after) {
373
+ t.Fatalf("Temporal projection recovery mutated task-system state: before=%+v after=%+v", before, after)
374
+ }
375
+ if !reflect.DeepEqual(beforeRunner, afterRunner) {
376
+ t.Fatalf("Temporal projection recovery mutated runner state: before=%+v after=%+v", beforeRunner, afterRunner)
377
+ }
378
+ if err := recoveryClient.Stop(context.Background()); err != nil {
379
+ t.Fatal(err)
380
+ }
381
+ select {
382
+ case err := <-done:
383
+ if err != nil {
384
+ t.Fatalf("recovery serveRoot: %v", err)
385
+ }
386
+ case <-time.After(15 * time.Second):
387
+ t.Fatal("recovery serveRoot did not stop")
388
+ }
389
+ }
390
+
391
+ func TestTemporalRecoveryRestoresOpenAndClosedExecutions(t *testing.T) {
392
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
393
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal projection rebuild against the local server")
394
+ }
395
+ fixture := newTemporalScenarioFixture(t)
396
+ var open runsvc.Run
397
+ waitScenario(t, 20*time.Second, func() bool {
398
+ var err error
399
+ open, err = fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
400
+ return err == nil && open.State == runsvc.StateWaiting && open.CurrentNode == "implement"
401
+ })
402
+
403
+ // Start a second execution directly through the public SDK. It is not
404
+ // returned by the task-system poller, so recovery must discover it only
405
+ // through Temporal Visibility and restore its closed projection row.
406
+ temporalClient, err := client.Dial(client.Options{HostPort: "localhost:7233", Namespace: fixture.namespace})
407
+ if err != nil {
408
+ t.Fatal(err)
409
+ }
410
+ defer temporalClient.Close()
411
+ wf, err := workflow.Parse(scenarioWorkflowName, scenarioWorkflowYAML)
412
+ if err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ closedStart := runsvc.Start{
416
+ ID: identity.NewRunID(scenarioRepo, scenarioWorkflowName, "CLOSED-1"),
417
+ Repo: scenarioRepo, RepoPath: scenarioRepoPath, Workflow: *wf,
418
+ Ticket: task.TicketRef{ID: "CLOSED-1", Key: "CLOSED-1", Title: "Closed ticket"},
419
+ Runtime: runsvc.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
420
+ }
421
+ closedHandle, err := temporalClient.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
422
+ ID: string(closedStart.ID), TaskQueue: temporalexec.TaskQueue,
423
+ WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
424
+ WorkflowExecutionErrorWhenAlreadyStarted: true,
425
+ }, temporalexec.TicketWorkflow, closedStart)
426
+ if err != nil {
427
+ t.Fatal(err)
428
+ }
429
+ completeTemporalRun(t, temporalClient, closedHandle, closedStart.ID)
430
+ unrelatedWorker := temporalworker.New(temporalClient, "unrelated-task-queue", temporalworker.Options{})
431
+ unrelatedWorker.RegisterWorkflow(unrelatedTemporalWorkflow)
432
+ if err := unrelatedWorker.Start(); err != nil {
433
+ t.Fatal(err)
434
+ }
435
+ unrelated, err := temporalClient.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
436
+ ID: "unrelated-workflow", TaskQueue: "unrelated-task-queue",
437
+ }, unrelatedTemporalWorkflow)
438
+ if err != nil {
439
+ unrelatedWorker.Stop()
440
+ t.Fatal(err)
441
+ }
442
+ if err := unrelated.Get(context.Background(), nil); err != nil {
443
+ unrelatedWorker.Stop()
444
+ t.Fatal(err)
445
+ }
446
+ unrelatedWorker.Stop()
447
+ beforeExecutions := listTemporalExecutions(t, temporalClient)
448
+ before := snapshotScenarioTaskState(fixture.tasks)
449
+ fixture.close(t)
450
+
451
+ ctx, cancel := context.WithCancel(context.Background())
452
+ defer cancel()
453
+ done := make(chan error, 1)
454
+ go func() { done <- serveRoot(ctx, fixture.paths, true) }()
455
+ recoveryClient := server.NewClient(fixture.paths.Socket)
456
+ waitForServerOrError(t, recoveryClient, done)
457
+ defer func() {
458
+ _ = recoveryClient.Stop(context.Background())
459
+ <-done
460
+ }()
461
+
462
+ runs, err := recoveryClient.ListRuns(context.Background(), runsvc.Filter{})
463
+ if err != nil {
464
+ t.Fatal(err)
465
+ }
466
+ var foundOpen, foundClosed bool
467
+ for _, recovered := range runs {
468
+ switch recovered.ID {
469
+ case open.ID:
470
+ foundOpen = recovered.State == runsvc.StateWaiting && recovered.CurrentNode == "implement" && recovered.Ticket.Key == scenarioTicket
471
+ case closedStart.ID:
472
+ foundClosed = recovered.State == runsvc.StateCompleted && recovered.Ticket.Key == "CLOSED-1" && recovered.Workflow == scenarioWorkflowName
473
+ }
474
+ }
475
+ if !foundOpen || !foundClosed {
476
+ t.Fatalf("recovered runs = %#v, open=%v closed=%v", runs, foundOpen, foundClosed)
477
+ }
478
+ assertNoEmbeddedExecutionTables(t, fixture.paths.Database)
479
+ afterExecutions := listTemporalExecutions(t, temporalClient)
480
+ if !reflect.DeepEqual(beforeExecutions, afterExecutions) {
481
+ t.Fatalf("Temporal recovery changed executions: before=%v after=%v", beforeExecutions, afterExecutions)
482
+ }
483
+ db, err := sql.Open("sqlite", fixture.paths.Database)
484
+ if err != nil {
485
+ t.Fatal(err)
486
+ }
487
+ defer db.Close()
488
+ for _, table := range []string{"relay_processed_reports", "relay_node_sessions"} {
489
+ var count int
490
+ if err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil {
491
+ t.Fatal(err)
492
+ }
493
+ if count != 0 {
494
+ t.Fatalf("recovery restored derived cache %s with %d rows", table, count)
495
+ }
496
+ }
497
+ var runtimeCount int
498
+ if err := db.QueryRow(`SELECT COUNT(*) FROM relay_node_runtime WHERE run_id = ?`, string(open.ID)).Scan(&runtimeCount); err != nil {
499
+ t.Fatal(err)
500
+ }
501
+ if runtimeCount == 0 {
502
+ t.Fatal("recovery did not restore the open run's runtime binding")
503
+ }
504
+ after := snapshotScenarioTaskState(fixture.tasks)
505
+ if before.parentStatus != after.parentStatus || before.mailboxCreates != after.mailboxCreates || before.comments != after.comments || !reflect.DeepEqual(before.transitions, after.transitions) {
506
+ t.Fatalf("Temporal rebuild mutated task-system state: before=%+v after=%+v", before, after)
507
+ }
508
+ }
509
+
510
+ type temporalRunSignal struct {
511
+ ReportID string `json:"reportId"`
512
+ Node string `json:"node"`
513
+ NodeVisitID runsvc.NodeVisitID `json:"nodeVisitId"`
514
+ Report workflow.Report `json:"report"`
515
+ }
516
+
517
+ func TestTemporalRecoveryRejectsUnreadableActiveHistory(t *testing.T) {
518
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
519
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run unreadable Temporal history recovery against the local server")
520
+ }
521
+ fixture := newTemporalScenarioFixture(t)
522
+ temporalClient, err := client.Dial(client.Options{HostPort: "localhost:7233", Namespace: fixture.namespace})
523
+ if err != nil {
524
+ t.Fatal(err)
525
+ }
526
+ malformedID := "malformed-active-history"
527
+ _, err = temporalClient.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
528
+ ID: malformedID, TaskQueue: temporalexec.TaskQueue,
529
+ }, temporalexec.TicketWorkflowName, "not-a-run-start")
530
+ if err != nil {
531
+ temporalClient.Close()
532
+ fixture.close(t)
533
+ t.Fatal(err)
534
+ }
535
+ beforeTasks := snapshotScenarioTaskState(fixture.tasks)
536
+ fixture.close(t)
537
+
538
+ ctx, cancel := context.WithCancel(context.Background())
539
+ defer cancel()
540
+ done := make(chan error, 1)
541
+ go func() { done <- serveRoot(ctx, fixture.paths, true) }()
542
+ deadline := time.NewTimer(20 * time.Second)
543
+ defer deadline.Stop()
544
+ var recoveryErr error
545
+ select {
546
+ case recoveryErr = <-done:
547
+ if recoveryErr == nil || (!strings.Contains(recoveryErr.Error(), "decode") && !strings.Contains(recoveryErr.Error(), "snapshot")) {
548
+ t.Fatalf("unreadable history recovery error = %v", recoveryErr)
549
+ }
550
+ case <-deadline.C:
551
+ t.Fatal("recovery unexpectedly started after unreadable active history")
552
+ }
553
+ if _, err := os.Stat(fixture.paths.Socket); !os.IsNotExist(err) {
554
+ t.Fatalf("recovery left a listening socket after unreadable history: %v", err)
555
+ }
556
+ afterTasks := snapshotScenarioTaskState(fixture.tasks)
557
+ if !reflect.DeepEqual(beforeTasks, afterTasks) {
558
+ t.Fatalf("unreadable history recovery mutated task-system state: before=%+v after=%+v", beforeTasks, afterTasks)
559
+ }
560
+ _ = temporalClient.CancelWorkflow(context.Background(), malformedID, "")
561
+ temporalClient.Close()
562
+ }
563
+
564
+ func TestTemporalRecoveryMissingClaimDoesNotStartReplacement(t *testing.T) {
565
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
566
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal missing-claim recovery against the local server")
567
+ }
568
+ fixture := newTemporalScenarioFixture(t)
569
+ waitScenario(t, 20*time.Second, func() bool {
570
+ run, err := fixture.cli.GetRunByTicket(context.Background(), scenarioTicket)
571
+ return err == nil && run.State == runsvc.StateWaiting && run.CurrentNode == "implement"
572
+ })
573
+ temporalClient, err := client.Dial(client.Options{HostPort: "localhost:7233", Namespace: fixture.namespace})
574
+ if err != nil {
575
+ t.Fatal(err)
576
+ }
577
+ defer temporalClient.Close()
578
+ beforeExecutions := listTemporalExecutions(t, temporalClient)
579
+ beforeTasks := snapshotScenarioTaskState(fixture.tasks)
580
+ fixture.close(t)
581
+ fixture.tasks.setRecoveryTickets([]task.Ticket{{
582
+ ID: "missing-claim", Key: "MISSING-CLAIM", Title: "Missing claim",
583
+ WorkflowClaims: []string{"wf:" + scenarioWorkflowName},
584
+ }}, 2)
585
+
586
+ ctx, cancel := context.WithCancel(context.Background())
587
+ defer cancel()
588
+ done := make(chan error, 1)
589
+ go func() { done <- serveRoot(ctx, fixture.paths, true) }()
590
+ recoveryClient := server.NewClient(fixture.paths.Socket)
591
+ waitForServerOrError(t, recoveryClient, done)
592
+ defer func() {
593
+ _ = recoveryClient.Stop(context.Background())
594
+ <-done
595
+ }()
596
+ if got, err := recoveryClient.GetRunByTicket(context.Background(), "MISSING-CLAIM"); err == nil || got.ID != "" {
597
+ t.Fatalf("missing claimed ticket acquired a recovery run: %+v, err=%v", got, err)
598
+ }
599
+ afterExecutions := listTemporalExecutions(t, temporalClient)
600
+ if !reflect.DeepEqual(beforeExecutions, afterExecutions) {
601
+ t.Fatalf("missing-claim recovery changed Temporal executions: before=%v after=%v", beforeExecutions, afterExecutions)
602
+ }
603
+ afterTasks := snapshotScenarioTaskState(fixture.tasks)
604
+ if !reflect.DeepEqual(beforeTasks, afterTasks) {
605
+ t.Fatalf("missing-claim recovery mutated task-system state: before=%+v after=%+v", beforeTasks, afterTasks)
606
+ }
607
+ }
608
+
609
+ func listTemporalExecutions(t *testing.T, c client.Client) map[string]string {
610
+ t.Helper()
611
+ out := map[string]string{}
612
+ var token []byte
613
+ for {
614
+ response, err := c.ListWorkflow(context.Background(), &workflowservice.ListWorkflowExecutionsRequest{
615
+ PageSize: 100, Query: "WorkflowType = 'TicketWorkflow' AND TaskQueue = 'relay-flow'", NextPageToken: token,
616
+ })
617
+ if err != nil {
618
+ t.Fatal(err)
619
+ }
620
+ for _, info := range response.Executions {
621
+ if info != nil && info.Execution != nil {
622
+ out[info.Execution.WorkflowId] = info.Execution.RunId
623
+ }
624
+ }
625
+ if len(response.NextPageToken) == 0 {
626
+ return out
627
+ }
628
+ token = response.NextPageToken
629
+ }
630
+ }
631
+
632
+ func completeTemporalRun(t *testing.T, c client.Client, handle client.WorkflowRun, id runsvc.ID) {
633
+ t.Helper()
634
+ steps := []struct {
635
+ node string
636
+ next string
637
+ }{
638
+ {node: "implement", next: "verify"},
639
+ {node: "verify", next: "pr-review"},
640
+ {node: "pr-review", next: "end"},
641
+ }
642
+ for i, step := range steps {
643
+ var state temporalexec.RunStateSnapshot
644
+ waitScenario(t, 20*time.Second, func() bool {
645
+ encoded, err := c.QueryWorkflow(context.Background(), string(id), handle.GetRunID(), "relay-flow/run-state-v1")
646
+ if err != nil || encoded.Get(&state) != nil {
647
+ return false
648
+ }
649
+ return state.Run.State == runsvc.StateWaiting && state.Run.CurrentNode == step.node && state.Run.CurrentNodeVisitID != ""
650
+ })
651
+ report := scenarioReport(workflow.OutcomeSuccess, step.next)
652
+ if err := c.SignalWorkflow(context.Background(), string(id), handle.GetRunID(), "report", temporalRunSignal{
653
+ ReportID: fmt.Sprintf("recovery-report-%d", i), Node: step.node,
654
+ NodeVisitID: state.Run.CurrentNodeVisitID, Report: report,
655
+ }); err != nil {
656
+ t.Fatal(err)
657
+ }
658
+ }
659
+ if err := handle.Get(context.Background(), nil); err != nil {
660
+ t.Fatalf("closed Temporal workflow: %v", err)
661
+ }
662
+ }
663
+
664
+ func unrelatedTemporalWorkflow(temporalworkflow.Context) error { return nil }
665
+
666
+ type scenarioTaskSnapshot struct {
667
+ parentStatus string
668
+ mailboxCreates int
669
+ comments int
670
+ transitions []string
671
+ mailboxes map[string]task.Mailbox
672
+ mailboxStatus map[string]string
673
+ commentBodies map[string]scenarioComment
674
+ createCounts map[string]int
675
+ completionCount map[string]int
676
+ }
677
+
678
+ func snapshotScenarioTaskState(tasks *scenarioTaskSystem) scenarioTaskSnapshot {
679
+ tasks.mu.Lock()
680
+ defer tasks.mu.Unlock()
681
+ mailboxes := make(map[string]task.Mailbox, len(tasks.mailboxes))
682
+ for node, mailbox := range tasks.mailboxes {
683
+ mailboxes[node] = mailbox
684
+ }
685
+ mailboxStatus := make(map[string]string, len(tasks.mailboxStatus))
686
+ for node, status := range tasks.mailboxStatus {
687
+ mailboxStatus[node] = status
688
+ }
689
+ commentBodies := make(map[string]scenarioComment, len(tasks.comments))
690
+ for marker, comment := range tasks.comments {
691
+ commentBodies[marker] = comment
692
+ }
693
+ createCounts := make(map[string]int, len(tasks.creates))
694
+ for node, count := range tasks.creates {
695
+ createCounts[node] = count
696
+ }
697
+ completionCount := make(map[string]int, len(tasks.completions))
698
+ for node, count := range tasks.completions {
699
+ completionCount[node] = count
700
+ }
701
+ return scenarioTaskSnapshot{
702
+ parentStatus: tasks.parentStatus, mailboxCreates: len(tasks.creates), comments: len(tasks.comments),
703
+ transitions: append([]string(nil), tasks.transitions...), mailboxes: mailboxes, mailboxStatus: mailboxStatus,
704
+ commentBodies: commentBodies, createCounts: createCounts, completionCount: completionCount,
705
+ }
706
+ }
707
+
708
+ type scenarioRunnerSnapshot struct {
709
+ environments map[string]runner.Environment
710
+ terminals map[string]scenarioTerminal
711
+ launches map[string]int
712
+ cleanups int
713
+ }
714
+
715
+ func snapshotScenarioRunnerState(r *scenarioRunner) scenarioRunnerSnapshot {
716
+ r.mu.Lock()
717
+ defer r.mu.Unlock()
718
+ environments := make(map[string]runner.Environment, len(r.environments))
719
+ for id, env := range r.environments {
720
+ environments[id] = env
721
+ }
722
+ terminals := make(map[string]scenarioTerminal, len(r.terminals))
723
+ for title, term := range r.terminals {
724
+ if term != nil {
725
+ terminals[title] = *term
726
+ }
727
+ }
728
+ launches := make(map[string]int, len(r.launches))
729
+ for title, count := range r.launches {
730
+ launches[title] = count
731
+ }
732
+ return scenarioRunnerSnapshot{environments: environments, terminals: terminals, launches: launches, cleanups: r.cleanups}
733
+ }