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,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
|
+
}
|