relay-flow 0.3.7-alpha → 0.3.9-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 (34) hide show
  1. package/README.md +5 -5
  2. package/cmd/relay-flow/observability_test.go +28 -0
  3. package/cmd/relay-flow/render.go +20 -4
  4. package/cmd/relay-flow/scenario_test.go +15 -13
  5. package/cmd/relay-flow/serve.go +116 -103
  6. package/internal/execution/goworkflows/activities.go +17 -0
  7. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  8. package/internal/execution/goworkflows/engine.go +278 -9
  9. package/internal/execution/goworkflows/fakes_test.go +10 -1
  10. package/internal/execution/goworkflows/interpreter.go +17 -1
  11. package/internal/execution/goworkflows/projection.go +8 -0
  12. package/internal/execution/goworkflows/recovery_test.go +482 -0
  13. package/internal/execution/projection/detail_test.go +38 -0
  14. package/internal/execution/projection/projection.go +80 -15
  15. package/internal/execution/temporal/activities.go +6 -0
  16. package/internal/execution/temporal/recovery.go +4 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/recover/recover.go +4 -0
  20. package/internal/repo/binding_test.go +92 -0
  21. package/internal/repo/poller.go +3 -0
  22. package/internal/repo/repo.go +82 -6
  23. package/internal/run/manager.go +41 -0
  24. package/internal/run/run_manager_test.go +56 -0
  25. package/internal/server/observability.go +1 -1
  26. package/internal/task/beads/beads.go +42 -4
  27. package/internal/task/beads/beads_test.go +15 -0
  28. package/internal/task/factory.go +41 -2
  29. package/internal/task/jira/jira.go +52 -28
  30. package/internal/workflow/service.go +53 -0
  31. package/internal/workflow/store.go +413 -19
  32. package/internal/workflow/store_test.go +235 -0
  33. package/internal/workflow/workflow.go +71 -0
  34. package/package.json +1 -1
@@ -27,8 +27,10 @@ import (
27
27
  "github.com/rajpopat27/relay-flow/internal/harness"
28
28
  "github.com/rajpopat27/relay-flow/internal/identity"
29
29
  "github.com/rajpopat27/relay-flow/internal/repo"
30
+ "github.com/rajpopat27/relay-flow/internal/retry"
30
31
  "github.com/rajpopat27/relay-flow/internal/run"
31
32
  "github.com/rajpopat27/relay-flow/internal/runner"
33
+ "github.com/rajpopat27/relay-flow/internal/task"
32
34
  "github.com/rajpopat27/relay-flow/internal/workflow"
33
35
  )
34
36
 
@@ -61,6 +63,7 @@ type Engine struct {
61
63
  runtime run.RuntimePolicy
62
64
 
63
65
  mu sync.RWMutex
66
+ cancelMu sync.Mutex // serializes cancellation history check + request
64
67
  snapshots map[run.ID]*workflow.Workflow // in-memory cache; history is authoritative
65
68
 
66
69
  workerCtx context.Context
@@ -177,6 +180,11 @@ func (e *Engine) Start(ctx context.Context) error {
177
180
  if err := e.actWorker.Start(e.workerCtx); err != nil {
178
181
  return fmt.Errorf("start activity worker: %w", err)
179
182
  }
183
+ // A cancellation request can outlive the engine instance that accepted it.
184
+ // Reconcile those projections before normal pollers start; a missing
185
+ // workflow instance is a confirmed terminal execution boundary, while
186
+ // other lookup failures remain retryable and are logged below.
187
+ e.reconcileCancelingRuns(ctx)
180
188
  // Startup retention sweep (pre-poller window): remove old terminal
181
189
  // projection rows and their engine histories; nonterminal runs stay.
182
190
  cutoff := time.Now().Add(-e.retention)
@@ -202,6 +210,7 @@ func (e *Engine) registerActivities() error {
202
210
  a.EnsureEnvironment,
203
211
  a.SetEnvironmentStatus,
204
212
  a.LoadNodeRuntime,
213
+ a.LoadCancellationReason,
205
214
  a.EnsureNodeRuntime,
206
215
  a.CloseTerminals,
207
216
  a.CleanupRun,
@@ -462,26 +471,286 @@ func (e *Engine) workflowOf(ctx context.Context, id run.ID) (*workflow.Workflow,
462
471
  return nil, fmt.Errorf("no workflow snapshot in history for run %s", id)
463
472
  }
464
473
 
465
- // CancelRun cancels the workflow instance; cleanup runs on a disconnected
466
- // workflow context and cannot interrupt an already-running activity.
474
+ // CancelRun requests cancellation of the durable workflow. The projection
475
+ // transition is compare-and-set: a concurrent completion wins if it reaches a
476
+ // terminal state first, while a persisted canceling state is the durable
477
+ // request that startup reconciliation retries.
467
478
  func (e *Engine) CancelRun(ctx context.Context, id run.ID, reason string) error {
468
- if _, err := e.runs.get(ctx, id); err != nil {
479
+ r, err := e.runs.beginCancellation(ctx, id, reason)
480
+ if err != nil {
469
481
  return fmt.Errorf("resolve run %s: %w", id, err)
470
482
  }
471
- if err := e.runs.updateState(ctx, id, run.StateCanceling, reason, nil); err != nil {
483
+ if r.State == run.StateCompleted || r.State == run.StateCanceled {
484
+ return nil
485
+ }
486
+ if r.State != run.StateCanceling {
487
+ return fmt.Errorf("cancel %s: state changed to %s", id, r.State)
488
+ }
489
+ // The reason persisted by the first successful CAS is authoritative for
490
+ // every later cancellation request.
491
+ return e.reconcileCancellation(ctx, r, r.LastError)
492
+ }
493
+
494
+ // isMissingWorkflowInstance identifies only confirmed absence of the active
495
+ // go-workflows execution. Database and context failures remain retryable.
496
+ func isMissingWorkflowInstance(err error) bool {
497
+ return errors.Is(err, sql.ErrNoRows) || errors.Is(err, backend.ErrInstanceNotFound)
498
+ }
499
+
500
+ // detachedContext preserves a caller deadline while detaching cancellation
501
+ // from the caller. Startup cleanup must not be able to outlive its bounded
502
+ // reconciliation window.
503
+ func detachedContext(ctx context.Context) (context.Context, context.CancelFunc) {
504
+ base := context.WithoutCancel(ctx)
505
+ if deadline, ok := ctx.Deadline(); ok {
506
+ return context.WithDeadline(base, deadline)
507
+ }
508
+ return base, func() {}
509
+ }
510
+
511
+ // lookupInstance returns the active execution when present. If only a finished
512
+ // row remains, finished is true; a missing row is the only absence outcome.
513
+ func (e *Engine) lookupInstance(ctx context.Context, id run.ID) (*goworkflow.Instance, bool, error) {
514
+ var execID string
515
+ var state int
516
+ err := e.db.QueryRowContext(ctx, `
517
+ SELECT execution_id, state FROM instances WHERE id = ?
518
+ ORDER BY CASE WHEN state = 0 THEN 0 ELSE 1 END, rowid DESC LIMIT 1`, string(id)).Scan(&execID, &state)
519
+ if err != nil {
520
+ return nil, false, fmt.Errorf("workflow instance %s not found: %w", id, err)
521
+ }
522
+ return &goworkflow.Instance{InstanceID: string(id), ExecutionID: execID}, state != 0, nil
523
+ }
524
+
525
+ // reconcileCancellation retries the durable cancellation request while an
526
+ // execution is active and reconciles finished/missing executions separately.
527
+ func (e *Engine) reconcileCancellation(ctx context.Context, r run.Run, reason string) error {
528
+ inst, finished, err := e.lookupInstance(ctx, r.ID)
529
+ if err != nil {
530
+ if isMissingWorkflowInstance(err) {
531
+ return e.finalizeMissingCancellation(ctx, r, reason)
532
+ }
533
+ return fmt.Errorf("resolve workflow instance: %w", err)
534
+ }
535
+ if finished {
536
+ return e.reconcileFinishedCancellation(ctx, r, inst, reason)
537
+ }
538
+ if e.client == nil {
539
+ return errors.New("workflow engine is not started")
540
+ }
541
+ if err := e.requestCancellation(ctx, inst); err != nil {
542
+ if isMissingWorkflowInstance(err) {
543
+ return e.finalizeMissingCancellation(ctx, r, reason)
544
+ }
472
545
  return err
473
546
  }
474
- inst, err := e.instance(ctx, id)
547
+ return nil
548
+ }
549
+
550
+ // requestCancellation checks durable history and appends at most one
551
+ // cancellation event for an execution. The mutex closes the check/request
552
+ // race between concurrent operator calls in this process; the relay lock
553
+ // prevents another relay-flow server from owning the same database.
554
+ func (e *Engine) requestCancellation(ctx context.Context, inst *goworkflow.Instance) error {
555
+ e.cancelMu.Lock()
556
+ defer e.cancelMu.Unlock()
557
+ var requested int
558
+ if err := e.db.QueryRowContext(ctx, `
559
+ SELECT EXISTS(
560
+ SELECT 1 FROM history
561
+ WHERE instance_id = ? AND execution_id = ? AND event_type = ?
562
+ UNION ALL
563
+ SELECT 1 FROM pending_events
564
+ WHERE instance_id = ? AND execution_id = ? AND event_type = ?
565
+ )`,
566
+ inst.InstanceID, inst.ExecutionID, history.EventType_WorkflowExecutionCanceled,
567
+ inst.InstanceID, inst.ExecutionID, history.EventType_WorkflowExecutionCanceled,
568
+ ).Scan(&requested); err != nil {
569
+ return fmt.Errorf("inspect cancellation history: %w", err)
570
+ }
571
+ if requested != 0 {
572
+ return nil
573
+ }
574
+ return e.client.CancelWorkflowInstance(ctx, inst)
575
+ }
576
+
577
+ // finalizeMissingCancellation performs the same roll-forward cleanup as the
578
+ // workflow's disconnected cancellation path when the workflow instance has
579
+ // genuinely disappeared. It intentionally does not recreate an execution.
580
+ func (e *Engine) finalizeMissingCancellation(ctx context.Context, r run.Run, reason string) error {
581
+ cleanupCtx, cleanupCancel := detachedContext(ctx)
582
+ defer cleanupCancel()
583
+ current, err := e.runs.get(cleanupCtx, r.ID)
584
+ if err != nil {
585
+ return fmt.Errorf("reconcile canceled run %s projection: %w", r.ID, err)
586
+ }
587
+ if current.State == run.StateCompleted || current.State == run.StateCanceled {
588
+ return nil
589
+ }
590
+ if current.State != run.StateCanceling {
591
+ return fmt.Errorf("reconcile canceled run %s: state changed to %s", r.ID, current.State)
592
+ }
593
+ if inst, finished, lookupErr := e.lookupInstance(cleanupCtx, r.ID); lookupErr == nil {
594
+ if finished {
595
+ return e.reconcileFinishedCancellation(cleanupCtx, current, inst, reason)
596
+ }
597
+ return fmt.Errorf("reconcile canceled run %s: workflow instance reappeared", r.ID)
598
+ } else if !isMissingWorkflowInstance(lookupErr) {
599
+ return fmt.Errorf("reconcile canceled run %s instance lookup: %w", r.ID, lookupErr)
600
+ }
601
+ return e.finalizeCancellationEffects(cleanupCtx, current, reason)
602
+ }
603
+
604
+ // finalizeCancellationEffects is shared by missing and already-canceled
605
+ // engine executions. The final projection transition is conditional so a
606
+ // concurrent terminal projection cannot be overwritten.
607
+ func (e *Engine) finalizeCancellationEffects(ctx context.Context, r run.Run, reason string) error {
608
+ if r.State == run.StateCompleted || r.State == run.StateCanceled {
609
+ return nil
610
+ }
611
+ if r.State != run.StateCanceling {
612
+ return fmt.Errorf("finalize canceled run %s: state changed to %s", r.ID, r.State)
613
+ }
614
+ repoInfo, ok := e.activities.Repos.Get(r.Repo)
615
+ if !ok {
616
+ return fmt.Errorf("reconcile canceled run %s: repo %q is no longer registered", r.ID, r.Repo)
617
+ }
618
+ work := run.Work{
619
+ RunID: r.ID,
620
+ LogicalID: r.LogicalID,
621
+ AttemptID: r.AttemptID,
622
+ Repo: r.Repo,
623
+ Workflow: r.Workflow,
624
+ Parent: r.Ticket,
625
+ Runtime: e.runtime,
626
+ }
627
+ if reason == "" {
628
+ reason = "operator requested cancellation"
629
+ }
630
+ if err := e.activities.FinalizeNodeRuntimes(ctx, work, repoInfo.Path, e.runtime); err != nil {
631
+ return fmt.Errorf("finalize canceled run %s terminals: %w", r.ID, err)
632
+ }
633
+ markerID := r.LogicalID
634
+ if markerID == "" {
635
+ markerID = r.ID
636
+ }
637
+ if err := e.activities.Comment(ctx, r.Repo, run.CommentWork{
638
+ RunID: r.ID,
639
+ Item: task.Target{Parent: r.Ticket},
640
+ Body: "Run canceled: " + reason,
641
+ Marker: run.CancellationMarker(markerID),
642
+ }); err != nil {
643
+ return fmt.Errorf("finalize canceled run %s comment: %w", r.ID, err)
644
+ }
645
+ finished := time.Now().UTC()
646
+ updated, err := e.runs.updateStateIf(ctx, r.ID, run.StateCanceling, run.StateCanceled, "", &finished)
475
647
  if err != nil {
476
- return fmt.Errorf("cancel %s: %w", id, err)
648
+ return fmt.Errorf("finalize canceled run %s projection: %w", r.ID, err)
477
649
  }
478
- if err := e.client.CancelWorkflowInstance(ctx, inst); err != nil {
479
- return fmt.Errorf("cancel %s: %w", id, err)
650
+ if !updated {
651
+ latest, getErr := e.runs.get(ctx, r.ID)
652
+ if getErr == nil && (latest.State == run.StateCanceled || latest.State == run.StateCompleted) {
653
+ return nil
654
+ }
655
+ if getErr != nil {
656
+ return fmt.Errorf("finalize canceled run %s projection after race: %w", r.ID, getErr)
657
+ }
658
+ return fmt.Errorf("finalize canceled run %s: state changed to %s", r.ID, latest.State)
480
659
  }
660
+ slog.Info("run canceled", "ticket", r.Ticket.Key, "runID", string(r.ID),
661
+ "repo", r.Repo, "workflow", r.Workflow, "state", run.StateCanceled)
481
662
  return nil
482
663
  }
483
664
 
484
- // instance resolves the current execution for the durable run ID.
665
+ // reconcileFinishedCancellation reads the terminal engine event before
666
+ // deciding whether a canceling projection should become completed or canceled.
667
+ func (e *Engine) reconcileFinishedCancellation(ctx context.Context, r run.Run, inst *goworkflow.Instance, reason string) error {
668
+ if e.backend == nil {
669
+ return errors.New("workflow backend is not started")
670
+ }
671
+ events, err := e.backend.GetWorkflowInstanceHistory(ctx, inst, nil)
672
+ if err != nil {
673
+ return fmt.Errorf("read finished workflow history: %w", err)
674
+ }
675
+ var finishedEvent *history.Event
676
+ canceledBeforeFinish := false
677
+ for _, event := range events {
678
+ switch event.Type {
679
+ case history.EventType_WorkflowExecutionCanceled:
680
+ // TicketWorkflow records this cancellation request first; after
681
+ // cancelCleanup returns, go-workflows may append a normal
682
+ // WorkflowExecutionFinished event. The earlier cancellation event
683
+ // remains authoritative for relay-flow semantics.
684
+ if finishedEvent == nil {
685
+ canceledBeforeFinish = true
686
+ }
687
+ case history.EventType_WorkflowExecutionFinished:
688
+ if finishedEvent == nil {
689
+ finishedEvent = event
690
+ }
691
+ case history.EventType_WorkflowExecutionTerminated:
692
+ return fmt.Errorf("workflow %s terminated without a cancellation or completion result", r.ID)
693
+ }
694
+ }
695
+ if canceledBeforeFinish {
696
+ return e.finalizeCancellationEffects(ctx, r, reason)
697
+ }
698
+ if finishedEvent == nil {
699
+ return fmt.Errorf("finished workflow %s has no terminal history event", r.ID)
700
+ }
701
+ {
702
+ finished := finishedEvent.Timestamp
703
+ updated, err := e.runs.updateStateIf(ctx, r.ID, run.StateCanceling, run.StateCompleted, "", &finished)
704
+ if err != nil {
705
+ return fmt.Errorf("reconcile completed run %s projection: %w", r.ID, err)
706
+ }
707
+ if !updated {
708
+ latest, getErr := e.runs.get(ctx, r.ID)
709
+ if getErr == nil && (latest.State == run.StateCompleted || latest.State == run.StateCanceled) {
710
+ return nil
711
+ }
712
+ if getErr != nil {
713
+ return fmt.Errorf("reconcile completed run %s projection after race: %w", r.ID, getErr)
714
+ }
715
+ return fmt.Errorf("reconcile completed run %s: state changed to %s", r.ID, latest.State)
716
+ }
717
+ return nil
718
+ }
719
+ }
720
+
721
+ // reconcileCancelingRuns retries the durable cancellation request for every
722
+ // stale canceling projection. An active instance is not skipped: its cancel
723
+ // event is re-submitted until the workflow accepts it.
724
+ func (e *Engine) reconcileCancelingRuns(ctx context.Context) {
725
+ active := true
726
+ runs, err := e.runs.list(ctx, run.Filter{Active: &active})
727
+ if err != nil {
728
+ slog.Warn("reconcile canceling runs unavailable", "error", err)
729
+ return
730
+ }
731
+ for _, r := range runs {
732
+ if r.State != run.StateCanceling {
733
+ continue
734
+ }
735
+ if err := e.retryStartupCancellation(ctx, r); err != nil {
736
+ slog.Warn("reconcile canceling run failed", "runID", r.ID, "error", err)
737
+ }
738
+ }
739
+ }
740
+
741
+ // retryStartupCancellation gives a durable canceling projection a bounded
742
+ // startup retry window. Normal polling deliberately does not recreate or
743
+ // advance canceling runs, so an active execution must receive its cancel
744
+ // event here or remain visibly retryable for the next restart.
745
+ func (e *Engine) retryStartupCancellation(ctx context.Context, r run.Run) error {
746
+ retryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
747
+ defer cancel()
748
+ return retry.Do(retryCtx, retry.DefaultBackoffPolicy, func() error {
749
+ return e.reconcileCancellation(retryCtx, r, r.LastError)
750
+ })
751
+ }
752
+
753
+ // instance resolves the current active execution for the durable run ID.
485
754
  func (e *Engine) instance(ctx context.Context, id run.ID) (*goworkflow.Instance, error) {
486
755
  var execID string
487
756
  err := e.db.QueryRowContext(ctx,
@@ -226,7 +226,16 @@ func (s *fakeTaskSystem) Comment(_ context.Context, target task.Target, body, ma
226
226
  if target.Mailbox != nil {
227
227
  key = target.Mailbox.Key
228
228
  }
229
- if s.failComments {
229
+ s.mu.Lock()
230
+ for _, comment := range s.comments {
231
+ if comment.Key == key && comment.Marker == marker {
232
+ s.mu.Unlock()
233
+ return nil
234
+ }
235
+ }
236
+ fail := s.failComments
237
+ s.mu.Unlock()
238
+ if fail {
230
239
  s.log.add("commentFail:" + key)
231
240
  return errTransient
232
241
  }
@@ -75,7 +75,23 @@ func (a *Activities) TicketWorkflow(ctx goworkflow.Context, start run.Start) err
75
75
  WorkflowTaskConfig: start.Workflow.TaskConfig,
76
76
  Runtime: start.Runtime,
77
77
  }
78
- return a.cancelCleanup(ctx, work, start.RepoPath, "canceled")
78
+ // Cancellation events do not carry relay-flow's operator reason. Read
79
+ // the reason persisted by CancelRun through the durable disconnected
80
+ // retry path so cleanup cannot write a permanently wrong comment when
81
+ // the projection is temporarily unavailable.
82
+ dctx := goworkflow.NewDisconnectedContext(ctx)
83
+ reason, reasonErr := retryLoop(dctx, start.ID, a, work, "",
84
+ func(ctx2 goworkflow.Context) goworkflow.Future[string] {
85
+ return goworkflow.ExecuteActivity[string](ctx2, noNativeRetries,
86
+ a.LoadCancellationReason, start.ID)
87
+ })
88
+ if reasonErr != nil {
89
+ return reasonErr
90
+ }
91
+ if reason == "" {
92
+ reason = "canceled"
93
+ }
94
+ return a.cancelCleanup(ctx, work, start.RepoPath, reason)
79
95
  }
80
96
  return err
81
97
  }
@@ -46,6 +46,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
46
46
  return p.shared().UpdateState(ctx, id, state, lastErr, finished)
47
47
  }
48
48
 
49
+ func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
50
+ return p.shared().BeginCancellation(ctx, id, reason)
51
+ }
52
+
53
+ func (p *RunProjection) updateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
54
+ return p.shared().UpdateStateIf(ctx, id, expected, state, lastErr, finished)
55
+ }
56
+
49
57
  func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
50
58
  return p.shared().UpdateRetry(ctx, id, status)
51
59
  }