relay-flow 0.2.3-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 (85) hide show
  1. package/README.md +121 -10
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +96 -13
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +100 -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 -3
  12. package/examples/config-reference.yaml +144 -0
  13. package/examples/minimal-beads-task-workflow.yaml +35 -0
  14. package/examples/minimal-jira-task-workflow.yaml +68 -0
  15. package/examples/workflow-reference.yaml +112 -0
  16. package/go.mod +37 -16
  17. package/go.sum +129 -61
  18. package/internal/config/machine.go +33 -1
  19. package/internal/config/machine_test.go +76 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/projection.go +47 -464
  22. package/internal/execution/projection/projection.go +867 -0
  23. package/internal/execution/projection/projection_test.go +347 -0
  24. package/internal/execution/temporal/activities.go +567 -0
  25. package/internal/execution/temporal/engine.go +384 -0
  26. package/internal/execution/temporal/engine_test.go +277 -0
  27. package/internal/execution/temporal/interpreter.go +736 -0
  28. package/internal/execution/temporal/operations.go +455 -0
  29. package/internal/execution/temporal/operations_test.go +101 -0
  30. package/internal/execution/temporal/recovery.go +194 -0
  31. package/internal/execution/temporal/recovery_runtime.go +41 -0
  32. package/internal/execution/temporal/recovery_test.go +102 -0
  33. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  34. package/internal/execution/temporal/spike_test.go +934 -0
  35. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  36. package/internal/harness/opencode/opencode.go +3 -1
  37. package/internal/harness/opencode/opencode_test.go +1 -1
  38. package/internal/harness/opencode/repo_setup.go +1 -1
  39. package/internal/harness/pi/config_test.go +46 -0
  40. package/internal/harness/pi/lifecycle_test.go +69 -0
  41. package/internal/harness/pi/pi.go +264 -0
  42. package/internal/harness/pi/pi_test.go +338 -0
  43. package/internal/harness/pi/prompt_test.go +150 -0
  44. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  45. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  46. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  47. package/internal/harness/pi/validation_test.go +144 -0
  48. package/internal/runner/herdr/baseref.go +60 -0
  49. package/internal/runner/herdr/herdr.go +592 -0
  50. package/internal/runner/herdr/herdr_test.go +708 -0
  51. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  52. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  53. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  54. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  55. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  56. package/internal/runner/herdr/herdrcli/response.go +116 -0
  57. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  58. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  59. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  60. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  61. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  62. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  63. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  64. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  65. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  66. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  67. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  68. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  69. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  70. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  71. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  72. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  73. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  74. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  75. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  76. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  77. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  78. package/internal/runner/orca/orca.go +33 -0
  79. package/internal/runner/orca/orca_test.go +33 -4
  80. package/internal/runner/runner.go +8 -0
  81. package/internal/task/beads/beads.go +0 -16
  82. package/internal/task/beads/config_compatibility_test.go +7 -8
  83. package/internal/task/jira/filters_test.go +32 -6
  84. package/internal/task/jira/jira.go +27 -17
  85. package/package.json +1 -1
@@ -0,0 +1,194 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+ "sort"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/identity"
13
+ enumspb "go.temporal.io/api/enums/v1"
14
+ "go.temporal.io/api/serviceerror"
15
+ workflowpb "go.temporal.io/api/workflow/v1"
16
+ workflowservice "go.temporal.io/api/workflowservice/v1"
17
+ )
18
+
19
+ // rebuildProjection reconstructs only relay-owned SQLite state. Temporal
20
+ // histories remain untouched and task/runner/harness dependencies are never
21
+ // called from this path.
22
+ func (e *Engine) rebuildProjection(ctx context.Context) error {
23
+ if e.client == nil {
24
+ return fmt.Errorf("Temporal client is not connected")
25
+ }
26
+ // Querying an active workflow requires a workflow worker to replay its
27
+ // history and install the query handlers. LocalActivityWorkerOnly prevents
28
+ // this recovery worker from running any external activity.
29
+ recoveryWorker := e.newWorker(true)
30
+ if err := recoveryWorker.Start(); err != nil {
31
+ stopWorker(recoveryWorker)
32
+ return fmt.Errorf("start Temporal recovery worker: %w", err)
33
+ }
34
+ defer stopWorker(recoveryWorker)
35
+
36
+ executions, err := listWorkflowExecutions(ctx, func(callCtx context.Context, request *workflowservice.ListWorkflowExecutionsRequest) (*workflowservice.ListWorkflowExecutionsResponse, error) {
37
+ return e.client.ListWorkflow(callCtx, request)
38
+ })
39
+ if err != nil {
40
+ return fmt.Errorf("enumerate Temporal TicketWorkflow executions: %w", err)
41
+ }
42
+ selected := selectTemporalExecutions(executions, time.Now().UTC(), e.retention)
43
+ // The boolean records whether the selected Visibility history is a
44
+ // confirmed running execution. A retained closed history is deliberately
45
+ // false so claimed-parent reconciliation performs exact DescribeWorkflowExecution
46
+ // and can discover a newer active execution for the same Workflow ID.
47
+ visible := map[string]bool{}
48
+ for _, info := range selected {
49
+ visible[info.Execution.WorkflowId] = info.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING
50
+ if info.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
51
+ if err := e.restoreProjection(ctx, info); err != nil {
52
+ return fmt.Errorf("rebuild active Temporal workflow %s: %w", info.Execution.WorkflowId, err)
53
+ }
54
+ continue
55
+ }
56
+ if err := e.restoreProjection(ctx, info); err != nil {
57
+ return fmt.Errorf("rebuild Temporal workflow %s: %w", info.Execution.WorkflowId, err)
58
+ }
59
+ }
60
+ return e.reconcileClaimedParents(ctx, visible)
61
+ }
62
+
63
+ func shouldRestoreTemporalExecution(info *workflowpb.WorkflowExecutionInfo, now time.Time, retention time.Duration) bool {
64
+ if info == nil || info.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING || info.CloseTime == nil {
65
+ return true
66
+ }
67
+ return !info.CloseTime.AsTime().Before(now.Add(-retention))
68
+ }
69
+
70
+ func selectTemporalExecutions(infos []*workflowpb.WorkflowExecutionInfo, now time.Time, retention time.Duration) []*workflowpb.WorkflowExecutionInfo {
71
+ selected := make(map[string]*workflowpb.WorkflowExecutionInfo)
72
+ for _, info := range infos {
73
+ if info == nil || info.Execution == nil || info.Type == nil || info.Type.Name != TicketWorkflowName || info.TaskQueue != TaskQueue || !shouldRestoreTemporalExecution(info, now, retention) {
74
+ continue
75
+ }
76
+ id := info.Execution.WorkflowId
77
+ current, ok := selected[id]
78
+ if !ok {
79
+ selected[id] = info
80
+ continue
81
+ }
82
+ other := info
83
+ if temporalExecutionPreferred(info, current) {
84
+ selected[id] = info
85
+ other = current
86
+ }
87
+ slog.Warn("multiple Temporal executions for Workflow ID; selecting one", "workflowID", id, "selectedRunID", selected[id].Execution.RunId, "otherRunID", other.Execution.RunId, "selectedStatus", selected[id].Status, "otherStatus", other.Status)
88
+ }
89
+ ids := make([]string, 0, len(selected))
90
+ for id := range selected {
91
+ ids = append(ids, id)
92
+ }
93
+ sort.Strings(ids)
94
+ out := make([]*workflowpb.WorkflowExecutionInfo, 0, len(ids))
95
+ for _, id := range ids {
96
+ out = append(out, selected[id])
97
+ }
98
+ return out
99
+ }
100
+
101
+ func temporalExecutionPreferred(candidate, current *workflowpb.WorkflowExecutionInfo) bool {
102
+ candidateRunning := candidate.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING
103
+ currentRunning := current.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING
104
+ if candidateRunning != currentRunning {
105
+ return candidateRunning
106
+ }
107
+ if candidate.StartTime != nil && current.StartTime != nil && !candidate.StartTime.AsTime().Equal(current.StartTime.AsTime()) {
108
+ return candidate.StartTime.AsTime().After(current.StartTime.AsTime())
109
+ }
110
+ if candidate.CloseTime != nil && current.CloseTime != nil && !candidate.CloseTime.AsTime().Equal(current.CloseTime.AsTime()) {
111
+ return candidate.CloseTime.AsTime().After(current.CloseTime.AsTime())
112
+ }
113
+ return candidate.Execution.RunId > current.Execution.RunId
114
+ }
115
+
116
+ func listWorkflowExecutions(ctx context.Context, list func(context.Context, *workflowservice.ListWorkflowExecutionsRequest) (*workflowservice.ListWorkflowExecutionsResponse, error)) ([]*workflowpb.WorkflowExecutionInfo, error) {
117
+ var token []byte
118
+ var executions []*workflowpb.WorkflowExecutionInfo
119
+ for {
120
+ response, err := list(ctx, &workflowservice.ListWorkflowExecutionsRequest{
121
+ PageSize: 100,
122
+ Query: fmt.Sprintf("WorkflowType = '%s' AND TaskQueue = '%s'", TicketWorkflowName, TaskQueue),
123
+ NextPageToken: token,
124
+ })
125
+ if err != nil {
126
+ return nil, err
127
+ }
128
+ if response == nil {
129
+ return nil, fmt.Errorf("empty response")
130
+ }
131
+ executions = append(executions, response.Executions...)
132
+ if len(response.NextPageToken) == 0 {
133
+ return executions, nil
134
+ }
135
+ token = response.NextPageToken
136
+ }
137
+ }
138
+
139
+ // reconcileClaimedParents closes the Visibility lag window without creating
140
+ // anything. Task-system polling is read-only; an exact deterministic Workflow
141
+ // ID is described and restored only when Temporal already owns that execution.
142
+ func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string]bool) error {
143
+ for _, rp := range e.deps.Repos.List() {
144
+ tickets, err := rp.TaskSystem.Poll(ctx)
145
+ if err != nil {
146
+ return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
147
+ }
148
+ for _, ticket := range tickets {
149
+ for _, claim := range ticket.WorkflowClaims {
150
+ if !strings.HasPrefix(claim, "wf:") {
151
+ continue
152
+ }
153
+ workflowName := strings.TrimPrefix(claim, "wf:")
154
+ var matched bool
155
+ for _, binding := range rp.Bindings() {
156
+ if binding.Workflow != nil && binding.Workflow.Name == workflowName {
157
+ matched = true
158
+ break
159
+ }
160
+ }
161
+ if !matched {
162
+ continue
163
+ }
164
+ expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
165
+ if visible[string(expectedID)] {
166
+ continue
167
+ }
168
+ info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
169
+ if err != nil {
170
+ var notFound *serviceerror.NotFound
171
+ if errors.As(err, &notFound) {
172
+ // This is the documented claim-before-run gap. Record it
173
+ // for operators, but leave creation to normal polling after
174
+ // recovery; this path must never start a replacement.
175
+ slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
176
+ continue
177
+ }
178
+ return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
179
+ }
180
+ if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
181
+ info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
182
+ continue
183
+ }
184
+ if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
185
+ continue
186
+ }
187
+ if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
188
+ return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return nil
194
+ }
@@ -0,0 +1,41 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ "github.com/rajpopat27/relay-flow/internal/runner"
10
+ )
11
+
12
+ // rediscoverMissingTerminals repairs only a missing derived terminal handle.
13
+ // Runner adapters that support title discovery perform a read-only lookup;
14
+ // recovery never creates a terminal or closes a healthy one.
15
+ func (e *Engine) rediscoverMissingTerminals(ctx context.Context, start run.Start, state RunStateSnapshot) error {
16
+ discoverer, ok := e.deps.Runner.(runner.TerminalDiscoverer)
17
+ if !ok {
18
+ return nil
19
+ }
20
+ spec := runner.RunSpec{RunID: start.ID, RepoName: start.Repo, RepoPath: start.RepoPath, TicketKey: start.Ticket.Key}
21
+ for _, binding := range state.RuntimeBindings {
22
+ if binding.Node == "" || binding.Node != state.Run.CurrentNode || binding.NodeVisitID == "" || binding.TerminalID != "" {
23
+ continue
24
+ }
25
+ title := start.Ticket.Key + ":" + binding.Node
26
+ terminal, found, err := discoverer.DiscoverTerminal(ctx, spec, title)
27
+ if err != nil {
28
+ return fmt.Errorf("discover terminal %q during Temporal recovery: %w", title, err)
29
+ }
30
+ if !found || terminal.ID == "" {
31
+ continue
32
+ }
33
+ if err := e.runs.UpdateNodeRuntime(ctx, projection.NodeRuntime{
34
+ RunID: start.ID, Node: binding.Node, TerminalID: terminal.ID,
35
+ SessionID: binding.SessionID, NodeVisitID: binding.NodeVisitID,
36
+ }); err != nil {
37
+ return fmt.Errorf("restore terminal binding %q: %w", title, err)
38
+ }
39
+ }
40
+ return nil
41
+ }
@@ -0,0 +1,102 @@
1
+ package temporal
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "testing"
7
+ "time"
8
+
9
+ commonpb "go.temporal.io/api/common/v1"
10
+ enumspb "go.temporal.io/api/enums/v1"
11
+ workflowpb "go.temporal.io/api/workflow/v1"
12
+ workflowservice "go.temporal.io/api/workflowservice/v1"
13
+ "google.golang.org/protobuf/types/known/timestamppb"
14
+ )
15
+
16
+ func TestRecoveryWorkerOptionsAreWorkflowOnly(t *testing.T) {
17
+ recovery := workerOptions(nil, true)
18
+ if !recovery.LocalActivityWorkerOnly {
19
+ t.Fatal("recovery worker enables non-local activities")
20
+ }
21
+ if recovery.MaxConcurrentWorkflowTaskExecutionSize != 10 || recovery.MaxConcurrentWorkflowTaskPollers != 2 {
22
+ t.Fatalf("recovery workflow options = %+v", recovery)
23
+ }
24
+ normal := workerOptions(nil, false)
25
+ if normal.LocalActivityWorkerOnly {
26
+ t.Fatal("normal worker was restricted to local activities")
27
+ }
28
+ }
29
+
30
+ func TestShouldRestoreTemporalExecutionHonorsLocalRetention(t *testing.T) {
31
+ now := time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)
32
+ retention := 30 * 24 * time.Hour
33
+ if !shouldRestoreTemporalExecution(&workflowpb.WorkflowExecutionInfo{Status: enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING}, now, retention) {
34
+ t.Fatal("active execution was filtered by local retention")
35
+ }
36
+ recent := timestamppb.New(now.Add(-retention + time.Minute))
37
+ if !shouldRestoreTemporalExecution(&workflowpb.WorkflowExecutionInfo{Status: enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED, CloseTime: recent}, now, retention) {
38
+ t.Fatal("recent closed execution was filtered")
39
+ }
40
+ old := timestamppb.New(now.Add(-retention - time.Minute))
41
+ if shouldRestoreTemporalExecution(&workflowpb.WorkflowExecutionInfo{Status: enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED, CloseTime: old}, now, retention) {
42
+ t.Fatal("expired closed execution was restored")
43
+ }
44
+ }
45
+
46
+ func TestSelectTemporalExecutionsKeepsOneCurrentHistoryPerWorkflowID(t *testing.T) {
47
+ now := time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC)
48
+ info := func(runID string, status enumspb.WorkflowExecutionStatus, started time.Time) *workflowpb.WorkflowExecutionInfo {
49
+ return &workflowpb.WorkflowExecutionInfo{
50
+ Execution: &commonpb.WorkflowExecution{WorkflowId: "same-id", RunId: runID},
51
+ Type: &commonpb.WorkflowType{Name: TicketWorkflowName}, TaskQueue: TaskQueue,
52
+ Status: status, StartTime: timestamppb.New(started),
53
+ }
54
+ }
55
+ selected := selectTemporalExecutions([]*workflowpb.WorkflowExecutionInfo{
56
+ info("old", enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED, now.Add(-time.Hour)),
57
+ info("current", enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING, now),
58
+ {
59
+ Execution: &commonpb.WorkflowExecution{WorkflowId: "other", RunId: "other"},
60
+ Type: &commonpb.WorkflowType{Name: "OtherWorkflow"}, TaskQueue: TaskQueue,
61
+ Status: enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING, StartTime: timestamppb.New(now),
62
+ },
63
+ }, now, 30*24*time.Hour)
64
+ if len(selected) != 1 || selected[0].Execution.RunId != "current" {
65
+ t.Fatalf("selected histories = %#v", selected)
66
+ }
67
+ }
68
+
69
+ func TestListWorkflowExecutionsPaginatesWithExactFilter(t *testing.T) {
70
+ var requests []*workflowservice.ListWorkflowExecutionsRequest
71
+ list := func(_ context.Context, request *workflowservice.ListWorkflowExecutionsRequest) (*workflowservice.ListWorkflowExecutionsResponse, error) {
72
+ requests = append(requests, request)
73
+ if len(requests) == 1 {
74
+ return &workflowservice.ListWorkflowExecutionsResponse{
75
+ Executions: []*workflowpb.WorkflowExecutionInfo{{Execution: &commonpb.WorkflowExecution{WorkflowId: "first"}}},
76
+ NextPageToken: []byte("next-page"),
77
+ }, nil
78
+ }
79
+ return &workflowservice.ListWorkflowExecutionsResponse{
80
+ Executions: []*workflowpb.WorkflowExecutionInfo{{Execution: &commonpb.WorkflowExecution{WorkflowId: "second"}}},
81
+ }, nil
82
+ }
83
+ executions, err := listWorkflowExecutions(context.Background(), list)
84
+ if err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ if len(executions) != 2 || executions[0].Execution.WorkflowId != "first" || executions[1].Execution.WorkflowId != "second" {
88
+ t.Fatalf("executions = %#v", executions)
89
+ }
90
+ if len(requests) != 2 || requests[0].PageSize != 100 || requests[1].PageSize != 100 {
91
+ t.Fatalf("pagination requests = %#v", requests)
92
+ }
93
+ wantQuery := "WorkflowType = '" + TicketWorkflowName + "' AND TaskQueue = '" + TaskQueue + "'"
94
+ for i, request := range requests {
95
+ if request.Query != wantQuery {
96
+ t.Fatalf("request %d query = %q, want %q", i, request.Query, wantQuery)
97
+ }
98
+ }
99
+ if !bytes.Equal(requests[1].NextPageToken, []byte("next-page")) {
100
+ t.Fatalf("second page token = %q", requests[1].NextPageToken)
101
+ }
102
+ }
@@ -0,0 +1,72 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
11
+ "github.com/rajpopat27/relay-flow/internal/identity"
12
+ "github.com/rajpopat27/relay-flow/internal/repo"
13
+ "github.com/rajpopat27/relay-flow/internal/run"
14
+ "github.com/rajpopat27/relay-flow/internal/task"
15
+ )
16
+
17
+ func TestTemporalWorkflowSnapshotSurvivesWorkerRestart(t *testing.T) {
18
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
19
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal snapshot restart coverage")
20
+ }
21
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
22
+ defer cancel()
23
+ namespace := "relay-flow-snapshot-" + string(identity.NewNodeVisitID())[:12]
24
+ if err := ensureSpikeNamespace(ctx, "localhost:7233", namespace); err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ path := filepath.Join(t.TempDir(), "state.db")
28
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ sys := &lagTaskSystem{}
32
+ registry := repo.NewRegistry()
33
+ wf := lagWorkflow()
34
+ registry.Replace(&repo.Repo{Name: "repo", Path: "/repo", TaskSystem: sys, Workflows: []repo.WorkflowBinding{{Workflow: &wf}}})
35
+ deps := Dependencies{Repos: registry, Runner: &lagRunner{}, Harness: &lagHarness{}, TaskSystem: "lag-task", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}
36
+ engine, err := New(path, deps)
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ if err := engine.Start(ctx); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ start := run.Start{ID: identity.NewRunID("repo", wf.Name, "SNAPSHOT-1"), Repo: "repo", RepoPath: "/repo", Workflow: wf, Ticket: task.TicketRef{ID: "SNAPSHOT-1", Key: "SNAPSHOT-1", Title: "Snapshot"}}
44
+ if created, err := engine.EnsureRun(ctx, start); err != nil || !created {
45
+ t.Fatalf("EnsureRun = %v, %v", created, err)
46
+ }
47
+ waitForLagState(t, ctx, engine, start.ID, run.StateWaiting)
48
+ before, err := engine.workflowFromHistory(ctx, start.ID, "")
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if err := engine.Shutdown(context.Background()); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+
56
+ restarted, err := New(path, deps)
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ if err := restarted.Start(ctx); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ defer restarted.Shutdown(context.Background())
64
+ after, err := restarted.workflowFromHistory(ctx, start.ID, "")
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if after.Name != before.Name || after.Nodes["work"].Description != before.Nodes["work"].Description || after.Nodes["work"].Agent != before.Nodes["work"].Agent {
69
+ t.Fatalf("snapshot changed across worker restart: before=%+v after=%+v", before, after)
70
+ }
71
+ waitForLagState(t, ctx, restarted, start.ID, run.StateWaiting)
72
+ }