relay-flow 0.2.4-alpha → 0.2.6-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +29 -18
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/beads_composition_test.go +3 -3
  4. package/cmd/relay-flow/main.go +94 -13
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +98 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -0
  12. package/examples/config-reference.yaml +7 -3
  13. package/examples/minimal-beads-task-workflow.yaml +2 -1
  14. package/examples/workflow-reference.yaml +2 -1
  15. package/go.mod +37 -16
  16. package/go.sum +129 -61
  17. package/internal/config/machine.go +33 -1
  18. package/internal/config/machine_test.go +76 -0
  19. package/internal/execution/goworkflows/activities.go +19 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/engine_test.go +1 -1
  22. package/internal/execution/goworkflows/node_runtime_test.go +113 -4
  23. package/internal/execution/goworkflows/projection.go +47 -464
  24. package/internal/execution/projection/projection.go +867 -0
  25. package/internal/execution/projection/projection_test.go +347 -0
  26. package/internal/execution/temporal/activities.go +586 -0
  27. package/internal/execution/temporal/engine.go +384 -0
  28. package/internal/execution/temporal/engine_test.go +277 -0
  29. package/internal/execution/temporal/interpreter.go +736 -0
  30. package/internal/execution/temporal/operations.go +455 -0
  31. package/internal/execution/temporal/operations_test.go +101 -0
  32. package/internal/execution/temporal/recovery.go +194 -0
  33. package/internal/execution/temporal/recovery_runtime.go +41 -0
  34. package/internal/execution/temporal/recovery_test.go +102 -0
  35. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  36. package/internal/execution/temporal/spike_test.go +934 -0
  37. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  38. package/internal/harness/harness.go +5 -0
  39. package/internal/harness/opencode/opencode.go +14 -3
  40. package/internal/harness/opencode/opencode_test.go +1 -1
  41. package/internal/harness/opencode/repo_setup.go +1 -1
  42. package/internal/harness/opencode/task_env_test.go +57 -0
  43. package/internal/harness/pi/pi.go +58 -47
  44. package/internal/harness/pi/pi_test.go +26 -10
  45. package/internal/harness/pi/prompt_test.go +30 -1
  46. package/internal/harness/pi/task_env_test.go +51 -0
  47. package/internal/harness/pi/validation_test.go +27 -51
  48. package/internal/repo/service.go +18 -8
  49. package/internal/runner/herdr/herdr.go +14 -0
  50. package/internal/runner/herdr/herdr_test.go +20 -0
  51. package/internal/runner/orca/orca.go +33 -0
  52. package/internal/runner/orca/orca_test.go +33 -4
  53. package/internal/runner/runner.go +8 -0
  54. package/internal/task/beads/agent_env_test.go +52 -0
  55. package/internal/task/beads/beads.go +87 -7
  56. package/internal/task/beads/beads_test.go +78 -9
  57. package/internal/task/beads/repo_composition_test.go +47 -3
  58. package/internal/task/factory.go +31 -2
  59. package/internal/task/task.go +10 -0
  60. package/package.json +1 -1
@@ -0,0 +1,455 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/runner"
13
+ domainworkflow "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ enumspb "go.temporal.io/api/enums/v1"
15
+ "go.temporal.io/api/serviceerror"
16
+ workflowpb "go.temporal.io/api/workflow/v1"
17
+ "go.temporal.io/sdk/client"
18
+ "go.temporal.io/sdk/converter"
19
+ "google.golang.org/protobuf/types/known/timestamppb"
20
+ )
21
+
22
+ func (e *Engine) ready() (client.Client, error) {
23
+ e.mu.Lock()
24
+ defer e.mu.Unlock()
25
+ if e.client == nil {
26
+ return nil, errors.New("temporal engine is not connected")
27
+ }
28
+ return e.client, nil
29
+ }
30
+
31
+ func isWorkflowNotFound(err error) bool {
32
+ var notFound *serviceerror.NotFound
33
+ return errors.As(err, &notFound)
34
+ }
35
+
36
+ func isAlreadyStarted(err error) bool {
37
+ var alreadyStarted *serviceerror.WorkflowExecutionAlreadyStarted
38
+ return errors.As(err, &alreadyStarted)
39
+ }
40
+
41
+ func validateTemporalExecutionInfo(info *workflowpb.WorkflowExecutionInfo, expectedID run.ID) error {
42
+ if info == nil || info.Execution == nil || info.Type == nil {
43
+ return errors.New("Temporal execution identity is incomplete")
44
+ }
45
+ if expectedID != "" && info.Execution.WorkflowId != string(expectedID) {
46
+ return fmt.Errorf("Temporal execution Workflow ID %q does not match %q", info.Execution.WorkflowId, expectedID)
47
+ }
48
+ if info.Type.Name != TicketWorkflowName || info.TaskQueue != TaskQueue {
49
+ return fmt.Errorf("Temporal execution %q has unexpected type/task queue (%q/%q)", info.Execution.WorkflowId, info.Type.Name, info.TaskQueue)
50
+ }
51
+ return nil
52
+ }
53
+
54
+ func (e *Engine) describe(ctx context.Context, id run.ID) (*client.WorkflowExecutionDescription, error) {
55
+ c, err := e.ready()
56
+ if err != nil {
57
+ return nil, err
58
+ }
59
+ return c.DescribeWorkflow(ctx, string(id), "")
60
+ }
61
+
62
+ func (e *Engine) describeInfo(ctx context.Context, id run.ID) (*workflowpb.WorkflowExecutionInfo, error) {
63
+ c, err := e.ready()
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ response, err := c.DescribeWorkflowExecution(ctx, string(id), "")
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ if response == nil || response.WorkflowExecutionInfo == nil {
72
+ return nil, fmt.Errorf("Temporal returned no execution info for %s", id)
73
+ }
74
+ return response.WorkflowExecutionInfo, nil
75
+ }
76
+
77
+ // EnsureRun uses the relay run ID as the Temporal Workflow ID. Describe is
78
+ // performed before a new start so an ambiguous projection write or process
79
+ // crash cannot create a second execution.
80
+ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
81
+ c, err := e.ready()
82
+ if err != nil {
83
+ return false, err
84
+ }
85
+ if start.LogicalID == "" {
86
+ start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
87
+ }
88
+ if start.AttemptID == 0 {
89
+ start.AttemptID = 1
90
+ }
91
+ start.Runtime = e.runtime
92
+
93
+ local, localErr := e.runs.Get(ctx, start.ID)
94
+ if localErr != nil && !projection.IsNotFound(localErr) {
95
+ return false, localErr
96
+ }
97
+ info, describeErr := c.DescribeWorkflowExecution(ctx, string(start.ID), "")
98
+ if describeErr == nil && info != nil && info.WorkflowExecutionInfo != nil {
99
+ if err := validateTemporalExecutionInfo(info.WorkflowExecutionInfo, start.ID); err != nil {
100
+ return false, err
101
+ }
102
+ if projection.IsNotFound(localErr) {
103
+ if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
104
+ return false, fmt.Errorf("restore Temporal run %s projection: %w", start.ID, err)
105
+ }
106
+ local, localErr = e.runs.Get(ctx, start.ID)
107
+ }
108
+ status := info.WorkflowExecutionInfo.Status
109
+ if status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
110
+ if err := e.reconcileRunningTerminal(ctx, start, info.WorkflowExecutionInfo.Execution.RunId); err != nil {
111
+ return false, err
112
+ }
113
+ return false, nil
114
+ }
115
+ if localErr == nil && local.State != run.StateCompleted && local.State != run.StateCanceled {
116
+ state, finished := stateForTemporalStatus(status, info.WorkflowExecutionInfo.CloseTime)
117
+ if err := e.runs.UpdateState(ctx, local.ID, state, "", finished); err != nil {
118
+ return false, err
119
+ }
120
+ }
121
+ return false, nil
122
+ }
123
+ if describeErr != nil && !isWorkflowNotFound(describeErr) {
124
+ return false, fmt.Errorf("describe Temporal workflow %s: %w", start.ID, describeErr)
125
+ }
126
+ if localErr == nil && (local.State == run.StateCompleted || local.State == run.StateCanceled) {
127
+ return false, nil
128
+ }
129
+ if localErr == nil && local.State != run.StateStarting {
130
+ return false, fmt.Errorf("Temporal workflow %s is missing while projection state is %q; refusing replacement execution", start.ID, local.State)
131
+ }
132
+ if projection.IsNotFound(localErr) {
133
+ if err := e.runs.InsertStart(ctx, start, time.Now().UTC()); err != nil {
134
+ return false, fmt.Errorf("insert Temporal run %s: %w", start.ID, err)
135
+ }
136
+ }
137
+ _, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
138
+ ID: string(start.ID),
139
+ TaskQueue: TaskQueue,
140
+ WorkflowIDReusePolicy: enumspb.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
141
+ WorkflowExecutionErrorWhenAlreadyStarted: true,
142
+ }, TicketWorkflow, start)
143
+ if err != nil {
144
+ if isAlreadyStarted(err) {
145
+ return false, nil
146
+ }
147
+ return false, fmt.Errorf("start Temporal workflow %s: %w", start.ID, err)
148
+ }
149
+ return true, nil
150
+ }
151
+
152
+ func (e *Engine) reconcileRunningTerminal(ctx context.Context, start run.Start, temporalRunID string) error {
153
+ state, err := e.queryRunState(ctx, start.ID, temporalRunID)
154
+ if err != nil {
155
+ return fmt.Errorf("query Temporal run state for terminal reconciliation %s: %w", start.ID, err)
156
+ }
157
+ if state.Run.State == run.StateCompleted || state.Run.State == run.StateCanceled || state.Run.State == run.StateCanceling || state.Run.CurrentNode == "" || state.Run.CurrentNode == "end" || state.Run.CurrentNodeVisitID == "" {
158
+ return nil
159
+ }
160
+ var binding NodeRuntimeBinding
161
+ for _, candidate := range state.RuntimeBindings {
162
+ if candidate.Node == state.Run.CurrentNode {
163
+ binding = candidate
164
+ break
165
+ }
166
+ }
167
+ if binding.TerminalID != "" {
168
+ terminal, live, err := e.deps.Runner.FindTerminal(ctx, runner.Terminal{
169
+ ID: binding.TerminalID, Title: start.Ticket.Key + ":" + state.Run.CurrentNode,
170
+ })
171
+ if err != nil {
172
+ return fmt.Errorf("find current Temporal run terminal %s: %w", start.ID, err)
173
+ }
174
+ if live && terminal.ID != "" {
175
+ return nil
176
+ }
177
+ }
178
+ c, err := e.ready()
179
+ if err != nil {
180
+ return err
181
+ }
182
+ if err := c.SignalWorkflow(ctx, string(start.ID), temporalRunID, reconcileSignalName, struct{}{}); err != nil {
183
+ return fmt.Errorf("signal terminal reconciliation for %s: %w", start.ID, err)
184
+ }
185
+ return nil
186
+ }
187
+
188
+ // SubmitReport validates against the workflow's current Temporal query state,
189
+ // then acknowledges only after the signal RPC has persisted the report in
190
+ // Temporal history. The SQLite receipt is only a fast-path cache.
191
+ func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.ReportAck, error) {
192
+ c, err := e.ready()
193
+ if err != nil {
194
+ return run.ReportAck{}, err
195
+ }
196
+ state, err := e.queryReportState(ctx, req.RunID, req.ReportID)
197
+ if err != nil {
198
+ if info, describeErr := e.describeInfo(ctx, req.RunID); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
199
+ return run.ReportAck{Accepted: true, Duplicate: true}, nil
200
+ }
201
+ return run.ReportAck{}, fmt.Errorf("query Temporal report state for %s: %w", req.RunID, err)
202
+ }
203
+ if state.Processed || state.State == run.StateCompleted || state.State == run.StateCanceled || state.CurrentNode != req.Node || state.CurrentNodeVisitID == "" {
204
+ return run.ReportAck{Accepted: true, Duplicate: true}, nil
205
+ }
206
+ wf, err := e.workflowFromHistory(ctx, req.RunID, "")
207
+ if err != nil {
208
+ return run.ReportAck{}, err
209
+ }
210
+ if err := wf.ValidateReport(req.Node, req.Report); err != nil {
211
+ return run.ReportAck{Accepted: false}, err
212
+ }
213
+ if err := c.SignalWorkflow(ctx, string(req.RunID), "", reportSignalName, reportSignal{
214
+ ReportID: req.ReportID, Node: req.Node, NodeVisitID: state.CurrentNodeVisitID, Report: req.Report,
215
+ }); err != nil {
216
+ if isWorkflowNotFound(err) {
217
+ if info, describeErr := e.describeInfo(ctx, req.RunID); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
218
+ return run.ReportAck{Accepted: true, Duplicate: true}, nil
219
+ }
220
+ }
221
+ return run.ReportAck{}, fmt.Errorf("signal Temporal report %s for %s: %w", req.ReportID, req.RunID, err)
222
+ }
223
+ return run.ReportAck{Accepted: true}, nil
224
+ }
225
+
226
+ // CancelRun requests cancellation of the exact app Workflow ID. Cleanup and
227
+ // the final canceled projection state are performed by the workflow.
228
+ func (e *Engine) CancelRun(ctx context.Context, id run.ID, reason string) error {
229
+ current, err := e.runs.Get(ctx, id)
230
+ if err != nil {
231
+ if projection.IsNotFound(err) {
232
+ // A request for an older attempt must never be redirected to the
233
+ // current attempt. It is a stale no-op when a newer logical run is
234
+ // present, otherwise preserve the missing-run error.
235
+ logicalID := run.ID(identity.LogicalRunID(id))
236
+ if latest, lookupErr := e.runs.FindByLogicalID(ctx, logicalID); lookupErr == nil && latest.ID != id {
237
+ return nil
238
+ }
239
+ }
240
+ return fmt.Errorf("resolve run %s: %w", id, err)
241
+ }
242
+ // Terminal and canceling attempts are fenced. In particular, explicit
243
+ // restart creates a distinct attempt ID and stale cancellation must not
244
+ // reach that newer Temporal execution.
245
+ if current.State == run.StateCompleted || current.State == run.StateCanceled || current.State == run.StateCanceling {
246
+ return nil
247
+ }
248
+ if err := e.runs.UpdateState(ctx, id, run.StateCanceling, reason, nil); err != nil {
249
+ return err
250
+ }
251
+ c, err := e.ready()
252
+ if err != nil {
253
+ return err
254
+ }
255
+ // Persist the operator's reason before requesting cancellation. The
256
+ // workflow consumes this internal signal during its disconnected cleanup;
257
+ // the public report/plugin wire remains unchanged.
258
+ if err := c.SignalWorkflow(ctx, string(id), "", cancelReasonSignalName, cancelReasonSignal{Reason: reason}); err != nil {
259
+ if isWorkflowNotFound(err) {
260
+ if info, describeErr := e.describeInfo(ctx, id); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
261
+ state, finished := stateForTemporalStatus(info.Status, info.CloseTime)
262
+ return e.runs.UpdateState(ctx, id, state, reason, finished)
263
+ }
264
+ }
265
+ return fmt.Errorf("signal cancellation reason for Temporal workflow %s: %w", id, err)
266
+ }
267
+ if err := c.CancelWorkflowWithOptions(ctx, client.CancelWorkflowOptions{WorkflowID: string(id), Reason: reason}); err != nil {
268
+ if isWorkflowNotFound(err) {
269
+ if info, describeErr := e.describeInfo(ctx, id); describeErr == nil && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
270
+ state, finished := stateForTemporalStatus(info.Status, info.CloseTime)
271
+ return e.runs.UpdateState(ctx, id, state, reason, finished)
272
+ }
273
+ }
274
+ return fmt.Errorf("cancel Temporal workflow %s: %w", id, err)
275
+ }
276
+ return nil
277
+ }
278
+
279
+ func (e *Engine) queryReportState(ctx context.Context, id run.ID, reportID string) (ReportStateSnapshot, error) {
280
+ c, err := e.ready()
281
+ if err != nil {
282
+ return ReportStateSnapshot{}, err
283
+ }
284
+ encoded, err := c.QueryWorkflow(ctx, string(id), "", reportStateQuery, ReportStateQuery{ReportID: reportID})
285
+ if err != nil {
286
+ return ReportStateSnapshot{}, err
287
+ }
288
+ var state ReportStateSnapshot
289
+ if err := encoded.Get(&state); err != nil {
290
+ return ReportStateSnapshot{}, err
291
+ }
292
+ return state, nil
293
+ }
294
+
295
+ func (e *Engine) queryRunState(ctx context.Context, id run.ID, temporalRunID string) (RunStateSnapshot, error) {
296
+ c, err := e.ready()
297
+ if err != nil {
298
+ return RunStateSnapshot{}, err
299
+ }
300
+ encoded, err := c.QueryWorkflow(ctx, string(id), temporalRunID, runStateQuery)
301
+ if err != nil {
302
+ return RunStateSnapshot{}, err
303
+ }
304
+ var state RunStateSnapshot
305
+ if err := encoded.Get(&state); err != nil {
306
+ return RunStateSnapshot{}, err
307
+ }
308
+ return state, nil
309
+ }
310
+
311
+ func (e *Engine) workflowFromHistory(ctx context.Context, id run.ID, temporalRunID string) (*domainworkflow.Workflow, error) {
312
+ c, err := e.ready()
313
+ if err != nil {
314
+ return nil, err
315
+ }
316
+ iterator := c.GetWorkflowHistory(ctx, string(id), temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
317
+ for iterator.HasNext() {
318
+ event, err := iterator.Next()
319
+ if err != nil {
320
+ return nil, fmt.Errorf("read Temporal history for %s: %w", id, err)
321
+ }
322
+ if event.GetEventType() != enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED {
323
+ continue
324
+ }
325
+ attrs := event.GetWorkflowExecutionStartedEventAttributes()
326
+ if attrs == nil || attrs.Input == nil {
327
+ continue
328
+ }
329
+ var start run.Start
330
+ if err := converter.GetDefaultDataConverter().FromPayloads(attrs.Input, &start); err != nil {
331
+ return nil, fmt.Errorf("decode Temporal snapshot for %s: %w", id, err)
332
+ }
333
+ wf := start.Workflow
334
+ return &wf, nil
335
+ }
336
+ return nil, fmt.Errorf("Temporal workflow %s has no run.Start snapshot", id)
337
+ }
338
+
339
+ func (e *Engine) workflowStart(ctx context.Context, id, temporalRunID string) (run.Start, error) {
340
+ c, err := e.ready()
341
+ if err != nil {
342
+ return run.Start{}, err
343
+ }
344
+ iterator := c.GetWorkflowHistory(ctx, id, temporalRunID, false, enumspb.HISTORY_EVENT_FILTER_TYPE_ALL_EVENT)
345
+ for iterator.HasNext() {
346
+ event, err := iterator.Next()
347
+ if err != nil {
348
+ return run.Start{}, err
349
+ }
350
+ if event.GetEventType() != enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED {
351
+ continue
352
+ }
353
+ attrs := event.GetWorkflowExecutionStartedEventAttributes()
354
+ if attrs == nil || attrs.Input == nil {
355
+ continue
356
+ }
357
+ var start run.Start
358
+ if err := converter.GetDefaultDataConverter().FromPayloads(attrs.Input, &start); err != nil {
359
+ return run.Start{}, fmt.Errorf("decode Temporal run.Start %s: %w", id, err)
360
+ }
361
+ if start.ID == "" {
362
+ start.ID = run.ID(id)
363
+ }
364
+ if start.ID != run.ID(id) {
365
+ return run.Start{}, fmt.Errorf("Temporal snapshot ID %s does not match Workflow ID %s", start.ID, id)
366
+ }
367
+ if start.LogicalID == "" {
368
+ start.LogicalID = run.ID(identity.LogicalRunID(start.ID))
369
+ }
370
+ if start.AttemptID == 0 {
371
+ start.AttemptID = 1
372
+ }
373
+ return start, nil
374
+ }
375
+ return run.Start{}, fmt.Errorf("Temporal workflow %s has no WorkflowExecutionStarted snapshot", id)
376
+ }
377
+
378
+ func stateForTemporalStatus(status enumspb.WorkflowExecutionStatus, closeTime *timestamppb.Timestamp) (run.State, *time.Time) {
379
+ var finished *time.Time
380
+ if closeTime != nil {
381
+ t := closeTime.AsTime()
382
+ finished = &t
383
+ }
384
+ if status == enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED || status == enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED {
385
+ return run.StateCanceled, finished
386
+ }
387
+ return run.StateCompleted, finished
388
+ }
389
+
390
+ // restoreProjection reconstructs the derived row and runtime bindings from
391
+ // Temporal state. It never calls a task-system mutation or starts a workflow.
392
+ func (e *Engine) restoreProjection(ctx context.Context, info *workflowpb.WorkflowExecutionInfo) error {
393
+ if err := validateTemporalExecutionInfo(info, ""); err != nil {
394
+ return err
395
+ }
396
+ id := run.ID(info.Execution.WorkflowId)
397
+ start, err := e.workflowStart(ctx, string(id), info.Execution.RunId)
398
+ if err != nil {
399
+ return err
400
+ }
401
+ started := time.Now().UTC()
402
+ if info.StartTime != nil {
403
+ started = info.StartTime.AsTime()
404
+ }
405
+ if err := e.runs.InsertStart(ctx, start, started); err != nil {
406
+ return err
407
+ }
408
+ state, queryErr := e.queryRunState(ctx, id, info.Execution.RunId)
409
+ if queryErr == nil {
410
+ if state.Run.CurrentNode != "" && state.Run.CurrentNodeVisitID != "" {
411
+ if err := e.runs.UpdateNode(ctx, id, state.Run.State, state.Run.CurrentNode, state.Run.CurrentNodeVisitID); err != nil {
412
+ return err
413
+ }
414
+ }
415
+ for _, binding := range state.RuntimeBindings {
416
+ if binding.Node == "" || binding.NodeVisitID == "" {
417
+ continue
418
+ }
419
+ if err := e.runs.UpdateNodeRuntime(ctx, projection.NodeRuntime{
420
+ RunID: id, Node: binding.Node, TerminalID: binding.TerminalID,
421
+ SessionID: binding.SessionID, NodeVisitID: binding.NodeVisitID,
422
+ UpdatedAt: time.Now().UTC(),
423
+ }); err != nil {
424
+ return err
425
+ }
426
+ }
427
+ if state.Run.State == run.StateCompleted || state.Run.State == run.StateCanceled {
428
+ finished := state.Run.FinishedAt
429
+ if finished == nil && info.CloseTime != nil {
430
+ closeTime := info.CloseTime.AsTime()
431
+ finished = &closeTime
432
+ }
433
+ return e.runs.UpdateState(ctx, id, state.Run.State, state.Run.LastError, finished)
434
+ }
435
+ if info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
436
+ stateValue, finished := stateForTemporalStatus(info.Status, info.CloseTime)
437
+ return e.runs.UpdateState(ctx, id, stateValue, state.Run.LastError, finished)
438
+ }
439
+ if err := e.runs.UpdateState(ctx, id, state.Run.State, state.Run.LastError, nil); err != nil {
440
+ return err
441
+ }
442
+ if err := e.runs.UpdateRetry(ctx, id, state.Run.Retry); err != nil {
443
+ return err
444
+ }
445
+ if err := e.rediscoverMissingTerminals(ctx, start, state); err != nil {
446
+ return err
447
+ }
448
+ return nil
449
+ }
450
+ stateValue, finished := stateForTemporalStatus(info.Status, info.CloseTime)
451
+ if info.Status == enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING {
452
+ return fmt.Errorf("query Temporal run-state for active workflow %s: %w", id, queryErr)
453
+ }
454
+ return e.runs.UpdateState(ctx, id, stateValue, "", finished)
455
+ }
@@ -0,0 +1,101 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
10
+ "github.com/rajpopat27/relay-flow/internal/run"
11
+ "github.com/rajpopat27/relay-flow/internal/task"
12
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
+ commonpb "go.temporal.io/api/common/v1"
14
+ workflowpb "go.temporal.io/api/workflow/v1"
15
+ _ "modernc.org/sqlite"
16
+ )
17
+
18
+ func TestValidateTemporalExecutionIdentity(t *testing.T) {
19
+ valid := func(workflowID, workflowType, taskQueue string) *workflowpb.WorkflowExecutionInfo {
20
+ return &workflowpb.WorkflowExecutionInfo{
21
+ Execution: &commonpb.WorkflowExecution{WorkflowId: workflowID},
22
+ Type: &commonpb.WorkflowType{Name: workflowType}, TaskQueue: taskQueue,
23
+ }
24
+ }
25
+ if err := validateTemporalExecutionInfo(valid("repo/wf/TICKET", TicketWorkflowName, TaskQueue), run.ID("repo/wf/TICKET")); err != nil {
26
+ t.Fatalf("valid execution identity: %v", err)
27
+ }
28
+ if err := validateTemporalExecutionInfo(valid("repo/wf/TICKET", "OtherWorkflow", TaskQueue), run.ID("repo/wf/TICKET")); err == nil {
29
+ t.Fatal("wrong workflow type was accepted")
30
+ }
31
+ if err := validateTemporalExecutionInfo(valid("repo/wf/TICKET", TicketWorkflowName, "other-queue"), run.ID("repo/wf/TICKET")); err == nil {
32
+ t.Fatal("wrong task queue was accepted")
33
+ }
34
+ if err := validateTemporalExecutionInfo(valid("other/wf/TICKET", TicketWorkflowName, TaskQueue), run.ID("repo/wf/TICKET")); err == nil {
35
+ t.Fatal("wrong Workflow ID was accepted")
36
+ }
37
+ }
38
+
39
+ func TestProjectionBookkeepingErrorsPropagateToTemporalActivities(t *testing.T) {
40
+ path := t.TempDir() + "/state.db"
41
+ db, err := sql.Open("sqlite", path)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ proj := &projection.RunProjection{DB: db}
46
+ if err := proj.Migrate(); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ if err := db.Close(); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ activities := &Activities{Runs: proj}
53
+ id := run.ID("repo/wf/TICKET")
54
+ if err := activities.ProjectionUpdateNode(context.Background(), id, run.StateWaiting, "node", "visit"); err == nil {
55
+ t.Fatal("ProjectionUpdateNode swallowed a closed-database error")
56
+ }
57
+ if err := activities.ProjectionUpdateNodeRuntimeVisit(context.Background(), id, "node", "visit"); err == nil {
58
+ t.Fatal("ProjectionUpdateNodeRuntimeVisit swallowed a closed-database error")
59
+ }
60
+ if err := activities.ProjectionRecordProcessedReport(context.Background(), id, "visit", "report"); err == nil {
61
+ t.Fatal("ProjectionRecordProcessedReport swallowed a closed-database error")
62
+ }
63
+ if err := activities.ProjectionUpdateState(context.Background(), id, run.StateWaiting, "", nil); err == nil {
64
+ t.Fatal("ProjectionUpdateState swallowed a closed-database error")
65
+ }
66
+ }
67
+
68
+ func TestCancelRunFencesTerminalAttempt(t *testing.T) {
69
+ path := t.TempDir() + "/state.db"
70
+ db, err := sql.Open("sqlite", path)
71
+ if err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ defer db.Close()
75
+ proj := &projection.RunProjection{DB: db}
76
+ if err := proj.Migrate(); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ start := run.Start{
80
+ ID: "repo/workflow/TICKET", Repo: "repo", Workflow: workflow.Workflow{Name: "workflow"},
81
+ Ticket: task.TicketRef{ID: "TICKET", Key: "TICKET"},
82
+ }
83
+ if err := proj.InsertStart(context.Background(), start, time.Now().UTC()); err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ finished := time.Now().UTC()
87
+ if err := proj.UpdateState(context.Background(), start.ID, run.StateCanceled, "canceled", &finished); err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ engine := &Engine{runs: proj}
91
+ if err := engine.CancelRun(context.Background(), start.ID, "stale cancellation"); err != nil {
92
+ t.Fatalf("CancelRun terminal attempt: %v", err)
93
+ }
94
+ got, err := proj.Get(context.Background(), start.ID)
95
+ if err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ if got.State != run.StateCanceled || got.LastError != "canceled" {
99
+ t.Fatalf("terminal attempt changed: %+v", got)
100
+ }
101
+ }