relay-flow 0.2.4-alpha → 0.2.5-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +20 -15
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +94 -13
  4. package/cmd/relay-flow/scenario_test.go +19 -2
  5. package/cmd/relay-flow/serve.go +98 -19
  6. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  7. package/cmd/relay-flow/temporal_init.go +170 -0
  8. package/cmd/relay-flow/temporal_init_test.go +217 -0
  9. package/cmd/relay-flow/temporal_report_test.go +733 -0
  10. package/examples/config-reference.yaml +2 -2
  11. package/examples/minimal-beads-task-workflow.yaml +2 -1
  12. package/examples/workflow-reference.yaml +2 -1
  13. package/go.mod +37 -16
  14. package/go.sum +129 -61
  15. package/internal/config/machine.go +33 -1
  16. package/internal/config/machine_test.go +76 -0
  17. package/internal/execution/goworkflows/engine.go +13 -38
  18. package/internal/execution/goworkflows/projection.go +47 -464
  19. package/internal/execution/projection/projection.go +867 -0
  20. package/internal/execution/projection/projection_test.go +347 -0
  21. package/internal/execution/temporal/activities.go +567 -0
  22. package/internal/execution/temporal/engine.go +384 -0
  23. package/internal/execution/temporal/engine_test.go +277 -0
  24. package/internal/execution/temporal/interpreter.go +736 -0
  25. package/internal/execution/temporal/operations.go +455 -0
  26. package/internal/execution/temporal/operations_test.go +101 -0
  27. package/internal/execution/temporal/recovery.go +194 -0
  28. package/internal/execution/temporal/recovery_runtime.go +41 -0
  29. package/internal/execution/temporal/recovery_test.go +102 -0
  30. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  31. package/internal/execution/temporal/spike_test.go +934 -0
  32. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  33. package/internal/harness/opencode/opencode.go +3 -1
  34. package/internal/harness/opencode/opencode_test.go +1 -1
  35. package/internal/harness/opencode/repo_setup.go +1 -1
  36. package/internal/harness/pi/pi.go +49 -46
  37. package/internal/harness/pi/pi_test.go +26 -10
  38. package/internal/harness/pi/prompt_test.go +30 -1
  39. package/internal/harness/pi/validation_test.go +27 -51
  40. package/internal/runner/herdr/herdr.go +14 -0
  41. package/internal/runner/herdr/herdr_test.go +20 -0
  42. package/internal/runner/orca/orca.go +33 -0
  43. package/internal/runner/orca/orca_test.go +33 -4
  44. package/internal/runner/runner.go +8 -0
  45. package/package.json +1 -1
@@ -0,0 +1,934 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "strings"
9
+ "sync"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/google/uuid"
15
+ "github.com/rajpopat27/relay-flow/internal/retry"
16
+ "github.com/rajpopat27/relay-flow/internal/run"
17
+ "github.com/rajpopat27/relay-flow/internal/task"
18
+ domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
19
+ enumspb "go.temporal.io/api/enums/v1"
20
+ historypb "go.temporal.io/api/history/v1"
21
+ "go.temporal.io/api/serviceerror"
22
+ workflowservice "go.temporal.io/api/workflowservice/v1"
23
+ "go.temporal.io/sdk/activity"
24
+ "go.temporal.io/sdk/client"
25
+ "go.temporal.io/sdk/converter"
26
+ temporalSDK "go.temporal.io/sdk/temporal"
27
+ "go.temporal.io/sdk/testsuite"
28
+ temporalworker "go.temporal.io/sdk/worker"
29
+ temporalworkflow "go.temporal.io/sdk/workflow"
30
+ "google.golang.org/protobuf/types/known/durationpb"
31
+ )
32
+
33
+ const (
34
+ spikeTaskQueue = "relay-flow"
35
+ spikeWorkflowType = "TemporalCompatibilityWorkflow"
36
+ spikeReportSignalName = "report"
37
+ spikeReconcileSignalName = "reconcile"
38
+ spikeFinishSignalName = "finish"
39
+ spikeRunStateQuery = "relay-flow/run-state-v1"
40
+ spikeReportStateQueryName = "relay-flow/report-state-v1"
41
+ spikeProgressQueryName = "compatibility/progress-v1"
42
+ spikeRetention = 30 * 24 * time.Hour
43
+ )
44
+
45
+ // spikeInput deliberately contains only the immutable run snapshot and scalar
46
+ // control values. It must remain safe to serialize into Temporal history.
47
+ type spikeInput struct {
48
+ Snapshot run.Start `json:"snapshot"`
49
+ Mode string `json:"mode"`
50
+ }
51
+
52
+ type spikeReportSignal struct {
53
+ ReportID string `json:"reportId"`
54
+ Node string `json:"node"`
55
+ NodeVisitID string `json:"nodeVisitId"`
56
+ }
57
+
58
+ type spikeReconcileSignal struct {
59
+ Reason string `json:"reason"`
60
+ }
61
+
62
+ type spikeNodeRuntimeBinding struct {
63
+ Node string `json:"node"`
64
+ TerminalID string `json:"terminalId"`
65
+ SessionID string `json:"sessionId"`
66
+ NodeVisitID string `json:"nodeVisitId"`
67
+ }
68
+
69
+ type spikeRunStateSnapshot struct {
70
+ Run run.Run `json:"run"`
71
+ RuntimeBindings []spikeNodeRuntimeBinding `json:"runtimeBindings"`
72
+ }
73
+
74
+ type spikeReportStateQuery struct {
75
+ ReportID string `json:"reportId"`
76
+ }
77
+
78
+ type spikeReportStateSnapshot struct {
79
+ CurrentNode string `json:"currentNode"`
80
+ CurrentNodeVisitID string `json:"currentNodeVisitId"`
81
+ State run.State `json:"state"`
82
+ Processed bool `json:"processed"`
83
+ }
84
+
85
+ // spikeProgressSnapshot is test-only observability. The two fixed query
86
+ // contracts above intentionally do not expose unbounded report history or
87
+ // implementation counters.
88
+ type spikeProgressSnapshot struct {
89
+ ReconcileCount int `json:"reconcileCount"`
90
+ DuplicateCount int `json:"duplicateCount"`
91
+ IgnoredCount int `json:"ignoredCount"`
92
+ }
93
+
94
+ type spikeResult struct {
95
+ WorkflowID string `json:"workflowId"`
96
+ RunID string `json:"runId"`
97
+ VisitID string `json:"visitId"`
98
+ ReportID string `json:"reportId"`
99
+ State run.State `json:"state"`
100
+ ReconcileCount int `json:"reconcileCount"`
101
+ DuplicateCount int `json:"duplicateCount"`
102
+ IgnoredCount int `json:"ignoredCount"`
103
+ ActivityResult string `json:"activityResult"`
104
+ ActivityErrType string `json:"activityErrType"`
105
+ }
106
+
107
+ type spikeActivityResult struct {
108
+ Value string `json:"value"`
109
+ }
110
+
111
+ type spikeActivities struct {
112
+ mu sync.Mutex
113
+ attempts map[string]int
114
+ cleanup atomic.Int32
115
+ }
116
+
117
+ func (a *spikeActivities) Record(ctx context.Context, input spikeInput) (spikeActivityResult, error) {
118
+ a.mu.Lock()
119
+ if a.attempts == nil {
120
+ a.attempts = make(map[string]int)
121
+ }
122
+ a.attempts[input.Snapshot.Ticket.Key]++
123
+ a.mu.Unlock()
124
+
125
+ switch input.Mode {
126
+ case "failure":
127
+ failure := retry.Classify(errors.New("compatibility transient failure"))
128
+ return spikeActivityResult{}, temporalSDK.NewApplicationError("compatibility failure", string(failure.Kind))
129
+ case "conflict":
130
+ failure := retry.Classify(retry.ConflictError(errors.New("compatibility conflict")))
131
+ return spikeActivityResult{}, temporalSDK.NewApplicationError("compatibility conflict", string(failure.Kind))
132
+ case "cancel-activity":
133
+ ticker := time.NewTicker(50 * time.Millisecond)
134
+ defer ticker.Stop()
135
+ for {
136
+ select {
137
+ case <-ctx.Done():
138
+ return spikeActivityResult{}, ctx.Err()
139
+ case <-ticker.C:
140
+ activity.RecordHeartbeat(ctx, "compatibility activity is still running")
141
+ if err := ctx.Err(); err != nil {
142
+ return spikeActivityResult{}, err
143
+ }
144
+ }
145
+ }
146
+ default:
147
+ return spikeActivityResult{Value: "activity-ok"}, nil
148
+ }
149
+ }
150
+
151
+ func (a *spikeActivities) Cleanup(context.Context, spikeInput) error {
152
+ a.cleanup.Add(1)
153
+ return nil
154
+ }
155
+
156
+ func (a *spikeActivities) attemptsFor(ticket string) int {
157
+ a.mu.Lock()
158
+ defer a.mu.Unlock()
159
+ return a.attempts[ticket]
160
+ }
161
+
162
+ func spikeActivityOptions() temporalworkflow.ActivityOptions {
163
+ return temporalworkflow.ActivityOptions{
164
+ StartToCloseTimeout: 5 * time.Minute,
165
+ WaitForCancellation: true,
166
+ RetryPolicy: &temporalSDK.RetryPolicy{MaximumAttempts: 1},
167
+ }
168
+ }
169
+
170
+ // spikeWorkflow intentionally exercises the SDK primitives that the
171
+ // production interpreter will use: package-level workflow registration,
172
+ // replay-safe side effects, durable timers, selectors, signals, queries,
173
+ // activity futures, disconnected cleanup, and Temporal time.
174
+ func spikeWorkflow(ctx temporalworkflow.Context, input spikeInput) (spikeResult, error) {
175
+ info := temporalworkflow.GetInfo(ctx)
176
+ consumed := make(map[string]bool)
177
+ state := spikeRunStateSnapshot{
178
+ Run: run.Run{
179
+ ID: input.Snapshot.ID,
180
+ LogicalID: input.Snapshot.LogicalID,
181
+ AttemptID: input.Snapshot.AttemptID,
182
+ Repo: input.Snapshot.Repo,
183
+ Workflow: input.Snapshot.Workflow.Name,
184
+ Ticket: input.Snapshot.Ticket,
185
+ State: run.StateStarting,
186
+ StartedAt: info.WorkflowStartTime,
187
+ UpdatedAt: temporalworkflow.Now(ctx),
188
+ },
189
+ }
190
+ state.Run.CurrentNode = "compatibility"
191
+ state.Run.CurrentNodeVisitID = ""
192
+ state.RuntimeBindings = []spikeNodeRuntimeBinding{{
193
+ Node: "compatibility",
194
+ TerminalID: "terminal-1",
195
+ SessionID: "session-1",
196
+ NodeVisitID: "",
197
+ }}
198
+
199
+ if err := temporalworkflow.SetQueryHandler(ctx, spikeRunStateQuery, func() (spikeRunStateSnapshot, error) {
200
+ return state, nil
201
+ }); err != nil {
202
+ return spikeResult{}, err
203
+ }
204
+ reconcileCount := 0
205
+ duplicateCount := 0
206
+ ignoredCount := 0
207
+ if err := temporalworkflow.SetQueryHandler(ctx, spikeReportStateQueryName, func(query spikeReportStateQuery) (spikeReportStateSnapshot, error) {
208
+ return spikeReportStateSnapshot{
209
+ CurrentNode: state.Run.CurrentNode,
210
+ CurrentNodeVisitID: string(state.Run.CurrentNodeVisitID),
211
+ State: state.Run.State,
212
+ Processed: consumed[query.ReportID],
213
+ }, nil
214
+ }); err != nil {
215
+ return spikeResult{}, err
216
+ }
217
+ if err := temporalworkflow.SetQueryHandler(ctx, spikeProgressQueryName, func() (spikeProgressSnapshot, error) {
218
+ return spikeProgressSnapshot{
219
+ ReconcileCount: reconcileCount,
220
+ DuplicateCount: duplicateCount,
221
+ IgnoredCount: ignoredCount,
222
+ }, nil
223
+ }); err != nil {
224
+ return spikeResult{}, err
225
+ }
226
+
227
+ var visitID string
228
+ if err := temporalworkflow.SideEffect(ctx, func(temporalworkflow.Context) interface{} {
229
+ return "visit-" + uuid.NewString()
230
+ }).Get(&visitID); err != nil {
231
+ return spikeResult{}, err
232
+ }
233
+ state.Run.CurrentNodeVisitID = run.NodeVisitID(visitID)
234
+ state.RuntimeBindings[0].NodeVisitID = visitID
235
+ state.Run.State = run.StateRunning
236
+ state.Run.UpdatedAt = temporalworkflow.Now(ctx)
237
+
238
+ activityCtx := temporalworkflow.WithActivityOptions(ctx, spikeActivityOptions())
239
+ state.Run.State = run.StateRunning
240
+ var activityResult spikeActivityResult
241
+ if err := temporalworkflow.ExecuteActivity(activityCtx, "Record", input).Get(activityCtx, &activityResult); err != nil {
242
+ state.Run.LastError = err.Error()
243
+ var applicationErr *temporalSDK.ApplicationError
244
+ if errors.As(err, &applicationErr) {
245
+ stateResult := spikeResult{
246
+ WorkflowID: info.WorkflowExecution.ID,
247
+ RunID: info.WorkflowExecution.RunID,
248
+ VisitID: visitID,
249
+ State: run.State("failed"),
250
+ ActivityErrType: applicationErr.Type(),
251
+ }
252
+ state.Run.State = run.State("failed")
253
+ return stateResult, err
254
+ }
255
+ if temporalSDK.IsCanceledError(err) || temporalSDK.IsCanceledError(ctx.Err()) {
256
+ state.Run.State = run.StateCanceling
257
+ cleanupCtx, cancel := temporalworkflow.NewDisconnectedContext(ctx)
258
+ defer cancel()
259
+ state.Run.State = run.StateCanceling
260
+ if cleanupErr := temporalworkflow.ExecuteActivity(
261
+ temporalworkflow.WithActivityOptions(cleanupCtx, spikeActivityOptions()),
262
+ "Cleanup", input,
263
+ ).Get(cleanupCtx, nil); cleanupErr != nil {
264
+ state.Run.LastError = cleanupErr.Error()
265
+ return spikeResult{}, cleanupErr
266
+ }
267
+ state.Run.State = run.StateCanceled
268
+ return spikeResult{
269
+ WorkflowID: info.WorkflowExecution.ID,
270
+ RunID: info.WorkflowExecution.RunID,
271
+ VisitID: visitID,
272
+ State: run.StateCanceled,
273
+ }, temporalSDK.NewCanceledError()
274
+ }
275
+ state.Run.State = run.State("failed")
276
+ return spikeResult{
277
+ WorkflowID: info.WorkflowExecution.ID,
278
+ RunID: info.WorkflowExecution.RunID,
279
+ VisitID: visitID,
280
+ State: run.State("failed"),
281
+ }, err
282
+ }
283
+ state.Run.UpdatedAt = temporalworkflow.Now(ctx)
284
+
285
+ if input.Mode == "complete" {
286
+ state.Run.State = run.StateCompleted
287
+ return spikeResult{
288
+ WorkflowID: info.WorkflowExecution.ID,
289
+ RunID: info.WorkflowExecution.RunID,
290
+ VisitID: visitID,
291
+ State: run.StateCompleted,
292
+ ActivityResult: activityResult.Value,
293
+ }, nil
294
+ }
295
+
296
+ state.Run.State = run.StateWaiting
297
+ timer := temporalworkflow.NewTimer(ctx, time.Hour)
298
+ reportCh := temporalworkflow.GetSignalChannel(ctx, spikeReportSignalName)
299
+ reconcileCh := temporalworkflow.GetSignalChannel(ctx, spikeReconcileSignalName)
300
+ finishCh := temporalworkflow.GetSignalChannel(ctx, spikeFinishSignalName)
301
+ var latestReport string
302
+
303
+ for {
304
+ selector := temporalworkflow.NewSelector(ctx)
305
+ selector.AddReceive(reportCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
306
+ if !more {
307
+ return
308
+ }
309
+ var report spikeReportSignal
310
+ channel.Receive(ctx, &report)
311
+ if report.Node != state.Run.CurrentNode || report.NodeVisitID != visitID {
312
+ ignoredCount++
313
+ return
314
+ }
315
+ if consumed[report.ReportID] {
316
+ duplicateCount++
317
+ return
318
+ }
319
+ consumed[report.ReportID] = true
320
+ latestReport = report.ReportID
321
+ state.Run.State = run.State("reported")
322
+ })
323
+ selector.AddReceive(reconcileCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
324
+ if !more {
325
+ return
326
+ }
327
+ var signal spikeReconcileSignal
328
+ channel.Receive(ctx, &signal)
329
+ reconcileCount++
330
+ })
331
+ selector.AddReceive(finishCh, func(channel temporalworkflow.ReceiveChannel, more bool) {
332
+ if !more {
333
+ return
334
+ }
335
+ channel.Receive(ctx, nil)
336
+ state.Run.State = run.StateCompleted
337
+ })
338
+ selector.AddReceive(ctx.Done(), func(channel temporalworkflow.ReceiveChannel, more bool) {
339
+ if more {
340
+ channel.Receive(ctx, nil)
341
+ }
342
+ })
343
+ selector.AddFuture(timer, func(temporalworkflow.Future) {
344
+ state.Run.State = run.State("timer-fired")
345
+ })
346
+ selector.Select(ctx)
347
+
348
+ if temporalSDK.IsCanceledError(ctx.Err()) {
349
+ cleanupCtx, cancel := temporalworkflow.NewDisconnectedContext(ctx)
350
+ defer cancel()
351
+ state.Run.State = run.StateCanceling
352
+ if cleanupErr := temporalworkflow.ExecuteActivity(
353
+ temporalworkflow.WithActivityOptions(cleanupCtx, spikeActivityOptions()),
354
+ "Cleanup", input,
355
+ ).Get(cleanupCtx, nil); cleanupErr != nil {
356
+ return spikeResult{}, cleanupErr
357
+ }
358
+ state.Run.State = run.StateCanceled
359
+ return spikeResult{
360
+ WorkflowID: info.WorkflowExecution.ID,
361
+ RunID: info.WorkflowExecution.RunID,
362
+ VisitID: visitID,
363
+ ReportID: latestReport,
364
+ State: run.StateCanceled,
365
+ ReconcileCount: reconcileCount,
366
+ DuplicateCount: duplicateCount,
367
+ IgnoredCount: ignoredCount,
368
+ }, temporalSDK.NewCanceledError()
369
+ }
370
+ if state.Run.State == run.StateCompleted {
371
+ return spikeResult{
372
+ WorkflowID: info.WorkflowExecution.ID,
373
+ RunID: info.WorkflowExecution.RunID,
374
+ VisitID: visitID,
375
+ ReportID: latestReport,
376
+ State: run.StateCompleted,
377
+ ReconcileCount: reconcileCount,
378
+ DuplicateCount: duplicateCount,
379
+ IgnoredCount: ignoredCount,
380
+ ActivityResult: activityResult.Value,
381
+ }, nil
382
+ }
383
+ if state.Run.State == run.State("timer-fired") {
384
+ return spikeResult{
385
+ WorkflowID: info.WorkflowExecution.ID,
386
+ RunID: info.WorkflowExecution.RunID,
387
+ VisitID: visitID,
388
+ State: run.State("timer-fired"),
389
+ ReconcileCount: reconcileCount,
390
+ DuplicateCount: duplicateCount,
391
+ IgnoredCount: ignoredCount,
392
+ }, nil
393
+ }
394
+ }
395
+ }
396
+
397
+ func newSpikeStart(ticket string) run.Start {
398
+ return run.Start{
399
+ ID: run.ID("repo/compatibility/" + ticket),
400
+ Repo: "compatibility-repo",
401
+ RepoPath: "/tmp/compatibility-repo",
402
+ Workflow: domainworkflow.Workflow{
403
+ Name: "compatibility",
404
+ Repos: []string{"compatibility-repo"},
405
+ Nodes: map[string]domainworkflow.Node{
406
+ "start": {OnSuccess: []domainworkflow.Route{{Target: "compatibility"}}},
407
+ "compatibility": {Type: domainworkflow.NodeAgent, Agent: "build", Description: "compatibility"},
408
+ "end": {},
409
+ },
410
+ },
411
+ Ticket: task.TicketRef{ID: ticket, Key: ticket, Title: "Compatibility ticket"},
412
+ Runtime: run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true},
413
+ }
414
+ }
415
+
416
+ func spikeWorkerOptions() temporalworker.Options {
417
+ return temporalworker.Options{
418
+ MaxConcurrentWorkflowTaskExecutionSize: 10,
419
+ MaxConcurrentActivityExecutionSize: 20,
420
+ MaxConcurrentWorkflowTaskPollers: 2,
421
+ MaxConcurrentActivityTaskPollers: 2,
422
+ WorkerStopTimeout: 30 * time.Second,
423
+ }
424
+ }
425
+
426
+ func startSpikeWorker(t *testing.T, temporalClient client.Client, activities *spikeActivities) temporalworker.Worker {
427
+ t.Helper()
428
+ w := temporalworker.New(temporalClient, spikeTaskQueue, spikeWorkerOptions())
429
+ w.RegisterWorkflowWithOptions(spikeWorkflow, temporalworkflow.RegisterOptions{Name: spikeWorkflowType})
430
+ w.RegisterActivity(activities)
431
+ if err := w.Start(); err != nil {
432
+ t.Fatalf("start Temporal worker: %v", err)
433
+ }
434
+ return w
435
+ }
436
+
437
+ func TestTemporalCompatibilitySerializableContracts(t *testing.T) {
438
+ start := newSpikeStart("COMPAT-1")
439
+ state := spikeRunStateSnapshot{
440
+ Run: run.Run{
441
+ ID: start.ID, LogicalID: start.ID, AttemptID: 1, Repo: start.Repo,
442
+ Workflow: start.Workflow.Name, Ticket: start.Ticket, State: run.StateWaiting,
443
+ StartedAt: time.Unix(100, 0).UTC(), UpdatedAt: time.Unix(200, 0).UTC(),
444
+ },
445
+ RuntimeBindings: []spikeNodeRuntimeBinding{{
446
+ Node: "coding", TerminalID: "term-1", SessionID: "session-1", NodeVisitID: "visit-1",
447
+ }},
448
+ }
449
+ query := spikeReportStateQuery{ReportID: "report-1"}
450
+ values, err := converter.GetDefaultDataConverter().ToPayloads(start, state, query)
451
+ if err != nil {
452
+ t.Fatalf("encode serializable Temporal values: %v", err)
453
+ }
454
+ var gotStart run.Start
455
+ var gotState spikeRunStateSnapshot
456
+ var gotQuery spikeReportStateQuery
457
+ if err := converter.GetDefaultDataConverter().FromPayloads(values, &gotStart, &gotState, &gotQuery); err != nil {
458
+ t.Fatalf("decode serializable Temporal values: %v", err)
459
+ }
460
+ if gotStart.ID != start.ID || gotStart.Workflow.Name != start.Workflow.Name || gotStart.Ticket.Key != start.Ticket.Key {
461
+ t.Fatalf("run.Start snapshot changed across serialization: %#v", gotStart)
462
+ }
463
+ if len(gotState.RuntimeBindings) != 1 || gotState.RuntimeBindings[0].NodeVisitID != "visit-1" {
464
+ t.Fatalf("runtime binding changed across serialization: %#v", gotState.RuntimeBindings)
465
+ }
466
+ if gotQuery != query {
467
+ t.Fatalf("report-state query changed across serialization: %#v", gotQuery)
468
+ }
469
+ }
470
+
471
+ func TestTemporalCompatibilityWorkflowTestSuite(t *testing.T) {
472
+ var suite testsuite.WorkflowTestSuite
473
+ env := suite.NewTestWorkflowEnvironment()
474
+ activities := &spikeActivities{}
475
+ env.RegisterWorkflowWithOptions(spikeWorkflow, temporalworkflow.RegisterOptions{Name: spikeWorkflowType})
476
+ env.RegisterActivity(activities)
477
+ env.SetStartWorkflowOptions(client.StartWorkflowOptions{ID: "unit-compatibility"})
478
+ env.ExecuteWorkflow(spikeWorkflow, spikeInput{Snapshot: newSpikeStart("UNIT-1"), Mode: "complete"})
479
+ if err := env.GetWorkflowError(); err != nil {
480
+ t.Fatalf("workflow test environment: %v", err)
481
+ }
482
+ var result spikeResult
483
+ if err := env.GetWorkflowResult(&result); err != nil {
484
+ t.Fatalf("workflow result: %v", err)
485
+ }
486
+ if result.State != run.StateCompleted || result.ActivityResult != "activity-ok" {
487
+ t.Fatalf("workflow result = %#v", result)
488
+ }
489
+ if result.VisitID == "" || result.WorkflowID != "unit-compatibility" {
490
+ t.Fatalf("workflow identity/result = %#v", result)
491
+ }
492
+ if got := activities.attemptsFor("UNIT-1"); got != 1 {
493
+ t.Fatalf("activity calls = %d, want one", got)
494
+ }
495
+ }
496
+
497
+ func TestTemporalCompatibilityWorkerOptions(t *testing.T) {
498
+ opts := spikeWorkerOptions()
499
+ if opts.MaxConcurrentWorkflowTaskExecutionSize != 10 || opts.MaxConcurrentActivityExecutionSize != 20 {
500
+ t.Fatalf("execution limits = workflow %d/activity %d, want 10/20", opts.MaxConcurrentWorkflowTaskExecutionSize, opts.MaxConcurrentActivityExecutionSize)
501
+ }
502
+ if opts.MaxConcurrentWorkflowTaskPollers != 2 || opts.MaxConcurrentActivityTaskPollers != 2 {
503
+ t.Fatalf("poller limits = workflow %d/activity %d, want 2/2", opts.MaxConcurrentWorkflowTaskPollers, opts.MaxConcurrentActivityTaskPollers)
504
+ }
505
+ if opts.WorkerStopTimeout != 30*time.Second {
506
+ t.Fatalf("worker stop timeout = %s, want 30s", opts.WorkerStopTimeout)
507
+ }
508
+ activityOpts := spikeActivityOptions()
509
+ if activityOpts.StartToCloseTimeout != 5*time.Minute || !activityOpts.WaitForCancellation || activityOpts.RetryPolicy == nil || activityOpts.RetryPolicy.MaximumAttempts != 1 {
510
+ t.Fatalf("activity options = %#v, want 5m, wait-for-cancellation, max attempts 1", activityOpts)
511
+ }
512
+ }
513
+
514
+ func TestTemporalCompatibilityLive(t *testing.T) {
515
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
516
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run against the local Temporal Server")
517
+ }
518
+ address := os.Getenv("RELAY_FLOW_TEMPORAL_ADDRESS")
519
+ if address == "" {
520
+ address = "localhost:7233"
521
+ }
522
+ namespace := os.Getenv("RELAY_FLOW_TEMPORAL_NAMESPACE")
523
+ if namespace == "" {
524
+ namespace = fmt.Sprintf("relay-flow-compat-%d", os.Getpid())
525
+ }
526
+ if namespace == client.DefaultNamespace || strings.TrimSpace(namespace) == "" {
527
+ t.Fatalf("live compatibility test requires a dedicated named namespace, got %q", namespace)
528
+ }
529
+
530
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
531
+ defer cancel()
532
+ if err := ensureSpikeNamespace(ctx, address, namespace); err != nil {
533
+ t.Fatalf("ensure dedicated Temporal namespace: %v", err)
534
+ }
535
+ temporalClient, err := client.Dial(client.Options{HostPort: address, Namespace: namespace})
536
+ if err != nil {
537
+ t.Fatalf("dial Temporal at %s namespace %s: %v", address, namespace, err)
538
+ }
539
+ defer temporalClient.Close()
540
+ activities := &spikeActivities{}
541
+ w := startSpikeWorker(t, temporalClient, activities)
542
+
543
+ // Explicit ID and running duplicate handling.
544
+ waitID := fmt.Sprintf("compat-wait-%d", time.Now().UnixNano())
545
+ waitRun, err := startSpikeWorkflow(ctx, temporalClient, waitID, spikeInput{Snapshot: newSpikeStart("WAIT-1"), Mode: "wait"})
546
+ if err != nil {
547
+ t.Fatalf("start waiting workflow: %v", err)
548
+ }
549
+ if waitRun.GetID() != waitID || waitRun.GetRunID() == "" {
550
+ t.Fatalf("workflow identity = id %q/run %q, want explicit ID and server run ID", waitRun.GetID(), waitRun.GetRunID())
551
+ }
552
+ if _, err := startSpikeWorkflow(ctx, temporalClient, waitID, spikeInput{Snapshot: newSpikeStart("WAIT-DUP"), Mode: "wait"}); err == nil {
553
+ t.Fatal("starting a running workflow ID unexpectedly succeeded")
554
+ } else {
555
+ var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
556
+ if !errors.As(err, &alreadyStarted) {
557
+ t.Fatalf("running duplicate error = %T %v, want WorkflowExecutionAlreadyStarted", err, err)
558
+ }
559
+ }
560
+ state := waitForSpikeState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), string(run.StateWaiting))
561
+ if state.Run.ID != run.ID(newSpikeStart("WAIT-1").ID) || state.Run.CurrentNodeVisitID == "" || state.Run.StartedAt.IsZero() {
562
+ t.Fatalf("run-state snapshot = %#v", state)
563
+ }
564
+ visitID := string(state.Run.CurrentNodeVisitID)
565
+ assertSpikeActivityHistory(t, ctx, temporalClient, waitID, waitRun.GetRunID(), waitID)
566
+
567
+ // Query the exact report-state shape before and after stale/current signals.
568
+ reportState := querySpikeReportState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "report-1")
569
+ if reportState.Processed || reportState.CurrentNodeVisitID != visitID || reportState.State != run.StateWaiting {
570
+ t.Fatalf("initial report-state snapshot = %#v", reportState)
571
+ }
572
+ if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReconcileSignalName, spikeReconcileSignal{Reason: "terminal missing"}); err != nil {
573
+ t.Fatalf("reconcile signal: %v", err)
574
+ }
575
+ waitForReconcileCount(t, ctx, temporalClient, waitID, waitRun.GetRunID(), 1)
576
+ if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReportSignalName, spikeReportSignal{ReportID: "stale", Node: "other", NodeVisitID: visitID}); err != nil {
577
+ t.Fatalf("stale report signal: %v", err)
578
+ }
579
+ waitForProgress(t, ctx, temporalClient, waitID, waitRun.GetRunID(), func(progress spikeProgressSnapshot) bool {
580
+ return progress.IgnoredCount >= 1
581
+ }, "stale report")
582
+ if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReportSignalName, spikeReportSignal{ReportID: "report-1", Node: "compatibility", NodeVisitID: visitID}); err != nil {
583
+ t.Fatalf("current report signal: %v", err)
584
+ }
585
+ waitForSpikeState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "reported")
586
+ if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeReportSignalName, spikeReportSignal{ReportID: "report-1", Node: "compatibility", NodeVisitID: visitID}); err != nil {
587
+ t.Fatalf("duplicate report signal: %v", err)
588
+ }
589
+ reportState = querySpikeReportState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "report-1")
590
+ if !reportState.Processed || reportState.CurrentNodeVisitID != visitID {
591
+ t.Fatalf("consumed report-state snapshot = %#v", reportState)
592
+ }
593
+ waitForDuplicateCount(t, ctx, temporalClient, waitID, waitRun.GetRunID(), 1)
594
+
595
+ // Stop and reconnect the worker while the workflow is still waiting.
596
+ w.Stop()
597
+ w = startSpikeWorker(t, temporalClient, activities)
598
+ waitForSpikeState(t, ctx, temporalClient, waitID, waitRun.GetRunID(), "reported")
599
+ if err := temporalClient.SignalWorkflow(ctx, waitID, waitRun.GetRunID(), spikeFinishSignalName, struct{}{}); err != nil {
600
+ t.Fatalf("finish signal after worker restart: %v", err)
601
+ }
602
+ var completed spikeResult
603
+ if err := waitRun.Get(ctx, &completed); err != nil {
604
+ t.Fatalf("wait workflow result after worker restart: %v", err)
605
+ }
606
+ if completed.State != run.StateCompleted || completed.ReportID != "report-1" || completed.ReconcileCount != 1 || completed.DuplicateCount != 1 || completed.IgnoredCount != 1 {
607
+ t.Fatalf("completed workflow result = %#v", completed)
608
+ }
609
+ assertSpikeHistorySnapshot(t, ctx, temporalClient, waitID, waitRun.GetRunID(), newSpikeStart("WAIT-1"))
610
+
611
+ // Failed executions may be reused with ALLOW_DUPLICATE_FAILED_ONLY.
612
+ failedID := fmt.Sprintf("compat-failed-%d", time.Now().UnixNano())
613
+ failedRun, err := startSpikeWorkflow(ctx, temporalClient, failedID, spikeInput{Snapshot: newSpikeStart("FAIL-1"), Mode: "failure"})
614
+ if err != nil {
615
+ t.Fatalf("start failed workflow: %v", err)
616
+ }
617
+ failedErr := failedRun.Get(ctx, nil)
618
+ if failedErr == nil {
619
+ t.Fatal("failed workflow unexpectedly completed")
620
+ }
621
+ var transientErr *temporalSDK.ApplicationError
622
+ if !errors.As(failedErr, &transientErr) || transientErr.Type() != string(retry.Transient) {
623
+ t.Fatalf("failed workflow error = %T %v, want Temporal ApplicationError type %q", failedErr, failedErr, retry.Transient)
624
+ }
625
+ if activities.attemptsFor("FAIL-1") != 1 {
626
+ t.Fatalf("failed activity attempts = %d, want native retry maximum one", activities.attemptsFor("FAIL-1"))
627
+ }
628
+ restarted, err := startSpikeWorkflow(ctx, temporalClient, failedID, spikeInput{Snapshot: newSpikeStart("FAIL-2"), Mode: "complete"})
629
+ if err != nil {
630
+ t.Fatalf("restart failed workflow with same ID: %v", err)
631
+ }
632
+ var restartResult spikeResult
633
+ if err := restarted.Get(ctx, &restartResult); err != nil || restartResult.State != run.StateCompleted {
634
+ t.Fatalf("restarted failed workflow = %#v, err %v", restartResult, err)
635
+ }
636
+
637
+ conflictID := fmt.Sprintf("compat-conflict-%d", time.Now().UnixNano())
638
+ conflictRun, err := startSpikeWorkflow(ctx, temporalClient, conflictID, spikeInput{Snapshot: newSpikeStart("CONFLICT-1"), Mode: "conflict"})
639
+ if err != nil {
640
+ t.Fatalf("start conflict workflow: %v", err)
641
+ }
642
+ conflictErr := conflictRun.Get(ctx, nil)
643
+ if conflictErr == nil {
644
+ t.Fatal("conflict workflow unexpectedly completed")
645
+ }
646
+ var conflictAppErr *temporalSDK.ApplicationError
647
+ if !errors.As(conflictErr, &conflictAppErr) || conflictAppErr.Type() != string(retry.Conflict) {
648
+ t.Fatalf("conflict workflow error = %T %v, want Temporal ApplicationError type %q", conflictErr, conflictErr, retry.Conflict)
649
+ }
650
+ if activities.attemptsFor("CONFLICT-1") != 1 {
651
+ t.Fatalf("conflict activity attempts = %d, want native retry maximum one", activities.attemptsFor("CONFLICT-1"))
652
+ }
653
+
654
+ // Cancellation during an activity runs disconnected cleanup and ends with
655
+ // Temporal's canceled status rather than completed.
656
+ cancelID := fmt.Sprintf("compat-cancel-%d", time.Now().UnixNano())
657
+ cancelRunHandle, err := startSpikeWorkflow(ctx, temporalClient, cancelID, spikeInput{Snapshot: newSpikeStart("CANCEL-1"), Mode: "cancel-activity"})
658
+ if err != nil {
659
+ t.Fatalf("start cancel workflow: %v", err)
660
+ }
661
+ waitForSpikeState(t, ctx, temporalClient, cancelID, cancelRunHandle.GetRunID(), string(run.StateRunning))
662
+ if err := temporalClient.CancelWorkflowWithOptions(ctx, client.CancelWorkflowOptions{WorkflowID: cancelID, RunID: cancelRunHandle.GetRunID(), Reason: "compatibility cancellation"}); err != nil {
663
+ t.Fatalf("cancel workflow: %v", err)
664
+ }
665
+ if err := cancelRunHandle.Get(ctx, nil); err == nil {
666
+ t.Fatal("canceled workflow unexpectedly completed")
667
+ }
668
+ waitForCleanup(t, activities)
669
+ cancelDescription, err := temporalClient.DescribeWorkflowExecution(ctx, cancelID, cancelRunHandle.GetRunID())
670
+ if err != nil {
671
+ t.Fatalf("describe canceled workflow: %v", err)
672
+ }
673
+ if cancelDescription.WorkflowExecutionInfo.GetStatus() != enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED {
674
+ t.Fatalf("canceled workflow status = %s", cancelDescription.WorkflowExecutionInfo.GetStatus())
675
+ }
676
+ canceledRestart, err := startSpikeWorkflow(ctx, temporalClient, cancelID, spikeInput{Snapshot: newSpikeStart("CANCEL-2"), Mode: "complete"})
677
+ if err != nil {
678
+ t.Fatalf("restart canceled workflow with same ID: %v", err)
679
+ }
680
+ var canceledRestartResult spikeResult
681
+ if err := canceledRestart.Get(ctx, &canceledRestartResult); err != nil || canceledRestartResult.State != run.StateCompleted {
682
+ t.Fatalf("restarted canceled workflow = %#v, err %v", canceledRestartResult, err)
683
+ }
684
+
685
+ // A completed execution cannot be reused under ALLOW_DUPLICATE_FAILED_ONLY.
686
+ completedID := fmt.Sprintf("compat-completed-%d", time.Now().UnixNano())
687
+ firstCompleted, err := startSpikeWorkflow(ctx, temporalClient, completedID, spikeInput{Snapshot: newSpikeStart("DONE-1"), Mode: "complete"})
688
+ if err != nil {
689
+ t.Fatalf("start completed workflow: %v", err)
690
+ }
691
+ if err := firstCompleted.Get(ctx, nil); err != nil {
692
+ t.Fatalf("completed workflow result: %v", err)
693
+ }
694
+ if _, err := startSpikeWorkflow(ctx, temporalClient, completedID, spikeInput{Snapshot: newSpikeStart("DONE-2"), Mode: "complete"}); err == nil {
695
+ t.Fatal("reusing a completed workflow ID unexpectedly succeeded")
696
+ } else {
697
+ var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
698
+ if !errors.As(err, &alreadyStarted) {
699
+ t.Fatalf("completed duplicate error = %T %v, want WorkflowExecutionAlreadyStarted", err, err)
700
+ }
701
+ }
702
+
703
+ w.Stop()
704
+ }
705
+
706
+ func startSpikeWorkflow(ctx context.Context, c client.Client, id string, input spikeInput) (client.WorkflowRun, error) {
707
+ return c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
708
+ ID: id,
709
+ TaskQueue: spikeTaskQueue,
710
+ WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
711
+ WorkflowExecutionErrorWhenAlreadyStarted: true,
712
+ }, spikeWorkflowType, input)
713
+ }
714
+
715
+ func ensureSpikeNamespace(ctx context.Context, address, namespace string) error {
716
+ ns, err := client.NewNamespaceClient(client.Options{HostPort: address})
717
+ if err != nil {
718
+ return err
719
+ }
720
+ defer ns.Close()
721
+ description, err := ns.Describe(ctx, namespace)
722
+ if err != nil {
723
+ var notFound *serviceerror.NamespaceNotFound
724
+ if !errors.As(err, &notFound) {
725
+ return err
726
+ }
727
+ if err := ns.Register(ctx, &workflowservice.RegisterNamespaceRequest{
728
+ Namespace: namespace,
729
+ Description: "relay-flow compatibility spike",
730
+ WorkflowExecutionRetentionPeriod: durationpb.New(spikeRetention),
731
+ }); err != nil {
732
+ var alreadyExists *serviceerror.NamespaceAlreadyExists
733
+ if !errors.As(err, &alreadyExists) {
734
+ return fmt.Errorf("register namespace: %w", err)
735
+ }
736
+ }
737
+ description, err = ns.Describe(ctx, namespace)
738
+ if err != nil {
739
+ return err
740
+ }
741
+ // Namespace registration is acknowledged before every frontend worker
742
+ // cache necessarily observes it. Leave a small propagation window so a
743
+ // first live spike run does not race namespace visibility.
744
+ time.Sleep(5 * time.Second)
745
+ }
746
+ if description.Config == nil || description.Config.WorkflowExecutionRetentionTtl == nil {
747
+ return fmt.Errorf("namespace %q has no workflow retention configuration", namespace)
748
+ }
749
+ if description.Config.WorkflowExecutionRetentionTtl.AsDuration() < spikeRetention {
750
+ return fmt.Errorf("namespace %q retention is %s, need at least %s", namespace, description.Config.WorkflowExecutionRetentionTtl.AsDuration(), spikeRetention)
751
+ }
752
+ return nil
753
+ }
754
+
755
+ func querySpikeState(ctx context.Context, c client.Client, workflowID, runID string) (spikeRunStateSnapshot, error) {
756
+ encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeRunStateQuery)
757
+ if err != nil {
758
+ return spikeRunStateSnapshot{}, err
759
+ }
760
+ var state spikeRunStateSnapshot
761
+ if err := encoded.Get(&state); err != nil {
762
+ return spikeRunStateSnapshot{}, err
763
+ }
764
+ return state, nil
765
+ }
766
+
767
+ func querySpikeReportState(t *testing.T, ctx context.Context, c client.Client, workflowID, runID, reportID string) spikeReportStateSnapshot {
768
+ t.Helper()
769
+ encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeReportStateQueryName, spikeReportStateQuery{ReportID: reportID})
770
+ if err != nil {
771
+ t.Fatalf("query report state %s: %v", reportID, err)
772
+ }
773
+ var state spikeReportStateSnapshot
774
+ if err := encoded.Get(&state); err != nil {
775
+ t.Fatalf("decode report state %s: %v", reportID, err)
776
+ }
777
+ return state
778
+ }
779
+
780
+ func waitForSpikeState(t *testing.T, ctx context.Context, c client.Client, workflowID, runID, want string) spikeRunStateSnapshot {
781
+ t.Helper()
782
+ for {
783
+ state, err := querySpikeState(ctx, c, workflowID, runID)
784
+ if err == nil && string(state.Run.State) == want {
785
+ return state
786
+ }
787
+ select {
788
+ case <-ctx.Done():
789
+ t.Fatalf("waiting for workflow %s state %q: %v (last state %#v)", workflowID, want, err, state)
790
+ case <-time.After(100 * time.Millisecond):
791
+ }
792
+ }
793
+ }
794
+
795
+ func waitForReconcileCount(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, want int) {
796
+ t.Helper()
797
+ waitForProgress(t, ctx, c, workflowID, runID, func(progress spikeProgressSnapshot) bool {
798
+ return progress.ReconcileCount >= want
799
+ }, "reconcile signal")
800
+ }
801
+
802
+ func waitForDuplicateCount(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, want int) {
803
+ t.Helper()
804
+ waitForProgress(t, ctx, c, workflowID, runID, func(progress spikeProgressSnapshot) bool {
805
+ return progress.DuplicateCount >= want
806
+ }, "duplicate report")
807
+ }
808
+
809
+ func waitForProgress(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, done func(spikeProgressSnapshot) bool, label string) {
810
+ t.Helper()
811
+ deadline := time.NewTimer(5 * time.Second)
812
+ defer deadline.Stop()
813
+ for {
814
+ progress, err := querySpikeProgressNoFatal(ctx, c, workflowID, runID)
815
+ if err == nil && done(progress) {
816
+ return
817
+ }
818
+ select {
819
+ case <-deadline.C:
820
+ t.Fatalf("waiting for %s (progress %#v, err %v)", label, progress, err)
821
+ case <-ctx.Done():
822
+ t.Fatalf("waiting for %s: %v", label, ctx.Err())
823
+ case <-time.After(100 * time.Millisecond):
824
+ }
825
+ }
826
+ }
827
+
828
+ func querySpikeReportStateNoFatal(ctx context.Context, c client.Client, workflowID, runID, reportID string) (spikeReportStateSnapshot, error) {
829
+ encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeReportStateQueryName, spikeReportStateQuery{ReportID: reportID})
830
+ if err != nil {
831
+ return spikeReportStateSnapshot{}, err
832
+ }
833
+ var state spikeReportStateSnapshot
834
+ if err := encoded.Get(&state); err != nil {
835
+ return spikeReportStateSnapshot{}, err
836
+ }
837
+ return state, nil
838
+ }
839
+
840
+ func querySpikeProgressNoFatal(ctx context.Context, c client.Client, workflowID, runID string) (spikeProgressSnapshot, error) {
841
+ encoded, err := c.QueryWorkflow(ctx, workflowID, runID, spikeProgressQueryName)
842
+ if err != nil {
843
+ return spikeProgressSnapshot{}, err
844
+ }
845
+ var progress spikeProgressSnapshot
846
+ if err := encoded.Get(&progress); err != nil {
847
+ return spikeProgressSnapshot{}, err
848
+ }
849
+ return progress, nil
850
+ }
851
+
852
+ func waitForCleanup(t *testing.T, activities *spikeActivities) {
853
+ t.Helper()
854
+ deadline := time.NewTimer(30 * time.Second)
855
+ defer deadline.Stop()
856
+ for activities.cleanup.Load() == 0 {
857
+ select {
858
+ case <-deadline.C:
859
+ t.Fatal("cancellation cleanup activity did not execute")
860
+ case <-time.After(100 * time.Millisecond):
861
+ }
862
+ }
863
+ }
864
+
865
+ func historyForSpike(ctx context.Context, c client.Client, workflowID, runID string) ([]*historypb.HistoryEvent, error) {
866
+ iterator := c.GetWorkflowHistory(ctx, workflowID, runID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
867
+ var events []*historypb.HistoryEvent
868
+ for iterator.HasNext() {
869
+ event, err := iterator.Next()
870
+ if err != nil {
871
+ return nil, err
872
+ }
873
+ events = append(events, event)
874
+ }
875
+ return events, nil
876
+ }
877
+
878
+ func assertSpikeActivityHistory(t *testing.T, ctx context.Context, c client.Client, workflowID, runID, expectedWorkflowID string) {
879
+ t.Helper()
880
+ events, err := historyForSpike(ctx, c, workflowID, runID)
881
+ if err != nil {
882
+ t.Fatalf("get workflow history: %v", err)
883
+ }
884
+ var started, scheduled, timerStarted, marker bool
885
+ for _, event := range events {
886
+ switch event.GetEventType() {
887
+ case enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED:
888
+ started = true
889
+ attrs := event.GetWorkflowExecutionStartedEventAttributes()
890
+ if attrs == nil || attrs.GetWorkflowId() != expectedWorkflowID || attrs.GetTaskQueue().GetName() != spikeTaskQueue || attrs.GetRetryPolicy() != nil {
891
+ t.Fatalf("workflow-start attributes = %#v", attrs)
892
+ }
893
+ case enumspb.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED:
894
+ scheduled = true
895
+ attrs := event.GetActivityTaskScheduledEventAttributes()
896
+ if attrs.GetStartToCloseTimeout().AsDuration() != 5*time.Minute || attrs.GetRetryPolicy().GetMaximumAttempts() != 1 {
897
+ t.Fatalf("activity schedule options = %#v", attrs)
898
+ }
899
+ case enumspb.EVENT_TYPE_TIMER_STARTED:
900
+ timerStarted = true
901
+ case enumspb.EVENT_TYPE_MARKER_RECORDED:
902
+ marker = true
903
+ }
904
+ }
905
+ if !started || !scheduled || !timerStarted || !marker {
906
+ t.Fatalf("history markers started=%v activity=%v timer=%v sideEffect=%v", started, scheduled, timerStarted, marker)
907
+ }
908
+ }
909
+
910
+ func assertSpikeHistorySnapshot(t *testing.T, ctx context.Context, c client.Client, workflowID, runID string, expected run.Start) {
911
+ t.Helper()
912
+ events, err := historyForSpike(ctx, c, workflowID, runID)
913
+ if err != nil {
914
+ t.Fatalf("get snapshot history: %v", err)
915
+ }
916
+ for _, event := range events {
917
+ if event.GetEventType() != enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED {
918
+ continue
919
+ }
920
+ attrs := event.GetWorkflowExecutionStartedEventAttributes()
921
+ if attrs == nil || attrs.Input == nil {
922
+ t.Fatal("workflow-start event has no input snapshot")
923
+ }
924
+ var got spikeInput
925
+ if err := converter.GetDefaultDataConverter().FromPayloads(attrs.Input, &got); err != nil {
926
+ t.Fatalf("decode workflow-start snapshot: %v", err)
927
+ }
928
+ if got.Snapshot.ID != expected.ID || got.Snapshot.Workflow.Name != expected.Workflow.Name || got.Snapshot.Ticket.Key != expected.Ticket.Key {
929
+ t.Fatalf("history snapshot = %#v, want %#v", got.Snapshot, expected)
930
+ }
931
+ return
932
+ }
933
+ t.Fatal("workflow-start event not found")
934
+ }