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.
- package/README.md +121 -10
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +96 -13
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +100 -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/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +35 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +112 -0
- 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/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +264 -0
- package/internal/harness/pi/pi_test.go +338 -0
- package/internal/harness/pi/prompt_test.go +150 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +144 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +592 -0
- package/internal/runner/herdr/herdr_test.go +708 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -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/internal/task/beads/beads.go +0 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/jira.go +27 -17
- 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, ¬Found) {
|
|
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
|
+
}
|