relay-flow 0.3.9-alpha → 0.3.11-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 (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. package/package.json +1 -1
@@ -50,6 +50,12 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
50
50
  slog.Warn("recover: skip repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
51
51
  continue
52
52
  }
53
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
54
+ if !ok {
55
+ slog.Warn("recover: skip repository without ownership capabilities", "repo", rp.Name,
56
+ "outcome", "ownership-capability-missing")
57
+ continue
58
+ }
53
59
  tickets, err := rp.TaskSystem.Poll(ctx)
54
60
  if err != nil {
55
61
  return fmt.Errorf("repo %q poll: %w", rp.Name, err)
@@ -65,9 +71,32 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
65
71
  continue
66
72
  }
67
73
  if err != nil {
68
- slog.Warn("recover: skip ticket with invalid claim", "ticket", ticket.Key, "error", err)
74
+ var ownerMismatch *router.ClaimOwnerMismatchError
75
+ var invalid *router.InvalidClaimError
76
+ if errors.As(err, &ownerMismatch) {
77
+ slog.Info("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
78
+ "workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch", "error", err)
79
+ } else if errors.As(err, &invalid) {
80
+ slog.Warn("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
81
+ "workflow", invalid.Workflow, "outcome", "invalid-claim", "error", err)
82
+ } else {
83
+ slog.Warn("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
84
+ "outcome", "error", "error", err)
85
+ }
69
86
  continue
70
87
  }
88
+ // Router ownership uses the poll snapshot. Re-read the current
89
+ // provider-owned ticket before any recovery mutation so a
90
+ // poll-to-recovery reassignment cannot close terminals or reset
91
+ // mailboxes for the wrong owner.
92
+ if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), wf.Name, wf.TaskConfig); err != nil {
93
+ if errors.Is(err, task.ErrOwnershipMismatch) {
94
+ slog.Info("recover route outcome", "repo", rp.Name, "ticket", ticket.Key,
95
+ "workflow", wf.Name, "outcome", "claim-owner-mismatch", "error", err)
96
+ continue
97
+ }
98
+ return fmt.Errorf("repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
99
+ }
71
100
  // Skip canceled parents: the cancellation marker is the
72
101
  // task-system recovery record.
73
102
  runID := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
@@ -1,6 +1,7 @@
1
1
  package repo
2
2
 
3
3
  import (
4
+ "context"
4
5
  "errors"
5
6
  "strings"
6
7
  "testing"
@@ -15,6 +16,21 @@ type bindingTaskSystem struct {
15
16
  compileErr map[string]error
16
17
  }
17
18
 
19
+ type ownershipBindingTaskSystem struct{ bindingTaskSystem }
20
+
21
+ type legacyBindingTaskSystem struct{ task.System }
22
+
23
+ func (legacyBindingTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
24
+ return func(task.Ticket) bool { return true }, nil
25
+ }
26
+
27
+ func (s ownershipBindingTaskSystem) CompileOwnershipFilter(values config.RawValues) (func(task.Ticket) bool, error) {
28
+ owner, _ := values["owner"].(string)
29
+ return func(ticket task.Ticket) bool {
30
+ return owner == "" || ticket.Fields["assignee"] == owner
31
+ }, nil
32
+ }
33
+
18
34
  func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Ticket) bool, error) {
19
35
  if name, ok := values["name"].(string); ok {
20
36
  if err := s.compileErr[name]; err != nil {
@@ -25,6 +41,53 @@ func (s bindingTaskSystem) CompileFilter(values config.RawValues) (func(task.Tic
25
41
  return func(task.Ticket) bool { return true }, nil
26
42
  }
27
43
 
44
+ func (s bindingTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
45
+ return func(task.Ticket) bool { return true }, nil
46
+ }
47
+
48
+ func (s bindingTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
49
+ return nil
50
+ }
51
+
52
+ func (s bindingTaskSystem) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflow string, _ config.RawValues) error {
53
+ return s.Claim(ctx, ticket, workflow)
54
+ }
55
+
56
+ func TestBindWorkflowsRejectsMissingOwnershipCapabilities(t *testing.T) {
57
+ registered := &Repo{Name: "payments", TaskSystem: legacyBindingTaskSystem{}}
58
+ registry := NewRegistry()
59
+ registry.Replace(registered)
60
+ wf := &workflow.Workflow{Name: "legacyFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy}
61
+ if err := registry.BindWorkflows([]*workflow.Workflow{wf}); err == nil || !strings.Contains(err.Error(), "ownership capabilities") {
62
+ t.Fatalf("BindWorkflows error = %v, want missing-capability failure", err)
63
+ }
64
+ if bindings := registered.Bindings(); len(bindings) != 0 {
65
+ t.Fatalf("bindings = %#v, want none after capability rejection", bindings)
66
+ }
67
+ }
68
+
69
+ func TestBindWorkflowsPublishesSeparateOwnershipMatcher(t *testing.T) {
70
+ system := ownershipBindingTaskSystem{bindingTaskSystem: bindingTaskSystem{}}
71
+ registered := &Repo{Name: "payments", TaskSystem: system}
72
+ registry := NewRegistry()
73
+ registry.Replace(registered)
74
+ wf := &workflow.Workflow{
75
+ Name: "ownerFlow", Repos: []string{"payments"}, Status: workflow.HealthHealthy,
76
+ TaskConfig: config.RawValues{"owner": "alice"},
77
+ }
78
+ if err := registry.BindWorkflows([]*workflow.Workflow{wf}); err != nil {
79
+ t.Fatal(err)
80
+ }
81
+ bindings := registered.Bindings()
82
+ if len(bindings) != 1 || bindings[0].Ownership == nil {
83
+ t.Fatalf("bindings = %#v, want a published ownership matcher", bindings)
84
+ }
85
+ if !bindings[0].Ownership(task.Ticket{Fields: map[string]any{"assignee": "alice"}}) ||
86
+ bindings[0].Ownership(task.Ticket{Fields: map[string]any{"assignee": "bob"}}) {
87
+ t.Fatal("published ownership matcher did not enforce the adapter predicate")
88
+ }
89
+ }
90
+
28
91
  func TestBindWorkflowsIsolatedPublishesHealthyWorkflows(t *testing.T) {
29
92
  system := bindingTaskSystem{compileErr: map[string]error{"bad": errors.New("invalid filter")}}
30
93
  registered := &Repo{Name: "payments", TaskSystem: system}
@@ -16,8 +16,11 @@ import (
16
16
  // repo. Repo.Workflows is a derived in-memory index rebuilt at startup and
17
17
  // after workflow submission/removal; Workflow.Repos is the source of truth.
18
18
  type WorkflowBinding struct {
19
- Workflow *workflow.Workflow
20
- Match func(task.Ticket) bool
19
+ Workflow *workflow.Workflow
20
+ Match func(task.Ticket) bool
21
+ // Ownership is compiled from the adapter-owned durable claim-owner
22
+ // predicate. It is mandatory for published automatic-routing bindings.
23
+ Ownership func(task.Ticket) bool
21
24
  }
22
25
 
23
26
  type Info struct {
@@ -124,8 +127,9 @@ func (r *Registry) BindWorkflowsIsolated(workflows []*workflow.Workflow) []Bindi
124
127
 
125
128
  func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) ([]BindingIssue, error) {
126
129
  type binding struct {
127
- wf *workflow.Workflow
128
- match func(task.Ticket) bool
130
+ wf *workflow.Workflow
131
+ match func(task.Ticket) bool
132
+ ownership func(task.Ticket) bool
129
133
  }
130
134
  byRepo := map[string][]binding{}
131
135
  issues := []BindingIssue{}
@@ -171,7 +175,44 @@ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) (
171
175
  failed = true
172
176
  break
173
177
  }
174
- byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match})
178
+ // Ownership is a separate matcher so a claimed ticket can bypass
179
+ // lifecycle filters while still remaining isolated to the adapter's
180
+ // effective assignee/owner. All automatic-routing adapters must
181
+ // expose the complete capability set; there is no unrestricted Claim
182
+ // fallback for a missing capability.
183
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
184
+ if !ok {
185
+ err = fmt.Errorf("workflow %q repo %q: task system lacks required ownership capabilities", wf.Name, repoName)
186
+ if !isolate {
187
+ return nil, err
188
+ }
189
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
190
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
191
+ failed = true
192
+ break
193
+ }
194
+ ownership, err := capabilities.CompileOwnershipFilter(wf.TaskConfig)
195
+ if err != nil {
196
+ err = fmt.Errorf("workflow %q repo %q: compile ownership filter: %w", wf.Name, repoName, err)
197
+ if !isolate {
198
+ return nil, err
199
+ }
200
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
201
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
202
+ failed = true
203
+ break
204
+ }
205
+ if ownership == nil {
206
+ err = fmt.Errorf("workflow %q repo %q: ownership compiler returned a nil matcher", wf.Name, repoName)
207
+ if !isolate {
208
+ return nil, err
209
+ }
210
+ wf.MarkBlocked(err.Error(), wf.RepairCommand)
211
+ issues = append(issues, BindingIssue{Workflow: wf, Error: err})
212
+ failed = true
213
+ break
214
+ }
215
+ byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match, ownership: ownership})
175
216
  }
176
217
  if failed {
177
218
  // A workflow is all-or-nothing across its referenced repositories;
@@ -197,7 +238,7 @@ func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) (
197
238
  binds := byRepo[name]
198
239
  next := make([]WorkflowBinding, 0, len(binds))
199
240
  for _, b := range binds {
200
- next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match})
241
+ next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match, Ownership: b.ownership})
201
242
  }
202
243
  sort.Slice(next, func(i, j int) bool { return next[i].Workflow.Name < next[j].Workflow.Name })
203
244
  rp.bindingsMu.Lock()
@@ -17,6 +17,11 @@ import (
17
17
  // polling handler ignores it.
18
18
  var ErrNoMatch = errors.New("ticket matches no workflow")
19
19
 
20
+ // ErrClaimOwnerMismatch means a ticket's single workflow claim is valid for
21
+ // this repo but the task adapter's ownership matcher rejects the current
22
+ // owner. It is distinct from invalid claims and no-match routing.
23
+ var ErrClaimOwnerMismatch = errors.New("claimed ticket owner does not match workflow")
24
+
20
25
  // AmbiguousError means an unclaimed ticket matches more than one workflow.
21
26
  // No ticket mutation occurs.
22
27
  type AmbiguousError struct {
@@ -40,14 +45,32 @@ func (e *InvalidClaimError) Error() string {
40
45
  return fmt.Sprintf("ticket %s has invalid workflow claim %q for repo %s", e.Ticket, e.Workflow, e.Repo)
41
46
  }
42
47
 
48
+ // ClaimOwnerMismatchError means a single claim names a workflow bound to the
49
+ // repo, but its adapter-owned ownership matcher rejects the current ticket.
50
+ type ClaimOwnerMismatchError struct {
51
+ Ticket string
52
+ Workflow string
53
+ Repo string
54
+ }
55
+
56
+ func (e *ClaimOwnerMismatchError) Error() string {
57
+ return fmt.Sprintf("ticket %s claim %q has a current owner that does not match repo %s", e.Ticket, e.Workflow, e.Repo)
58
+ }
59
+
60
+ func (e *ClaimOwnerMismatchError) Unwrap() error { return ErrClaimOwnerMismatch }
61
+
43
62
  const claimPrefix = "wf:"
44
63
 
45
64
  // ResolveWorkflow routes a ticket to exactly one workflow following the
46
65
  // deterministic routing order: multiple claims are invalid; a single claim
47
- // resolves directly from repo bindings without re-running filters; an
48
- // unknown or unbound claim is invalid; otherwise precompiled matchers run —
49
- // zero matches is ErrNoMatch, one match wins, several is ambiguous.
66
+ // resolves directly from repo bindings after the adapter-owned ownership
67
+ // matcher accepts it; an unknown or unbound claim is invalid; otherwise
68
+ // precompiled lifecycle matchers run — zero matches is ErrNoMatch, one match
69
+ // wins, several is ambiguous.
50
70
  func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workflow, error) {
71
+ if registered == nil {
72
+ return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: "", Repo: ""}
73
+ }
51
74
  claims := ticket.WorkflowClaims
52
75
  if len(claims) > 1 {
53
76
  return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: strings.Join(claims, ","), Repo: registered.Name}
@@ -56,9 +79,20 @@ func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workf
56
79
  if len(claims) == 1 {
57
80
  name := strings.TrimPrefix(claims[0], claimPrefix)
58
81
  for _, b := range bindings {
59
- if b.Workflow.Name == name {
60
- return b.Workflow, nil
82
+ if b.Workflow.Name != name {
83
+ continue
84
+ }
85
+ // A claimed ticket intentionally bypasses the complete matcher,
86
+ // preserving lifecycle routing after status/label changes. The
87
+ // separate ownership matcher is the only claimed-path gate. A
88
+ // missing published matcher fails closed rather than transferring a
89
+ // legacy claim to whichever server sees it first.
90
+ if b.Ownership == nil || !b.Ownership(ticket) {
91
+ return nil, &ClaimOwnerMismatchError{
92
+ Ticket: ticket.Key, Workflow: name, Repo: registered.Name,
93
+ }
61
94
  }
95
+ return b.Workflow, nil
62
96
  }
63
97
  return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: name, Repo: registered.Name}
64
98
  }
@@ -36,7 +36,14 @@ func wf(name string) *workflow.Workflow {
36
36
  }
37
37
 
38
38
  func binding(w *workflow.Workflow, match func(task.Ticket) bool) repo.WorkflowBinding {
39
- return repo.WorkflowBinding{Workflow: w, Match: match}
39
+ return repo.WorkflowBinding{
40
+ Workflow: w, Match: match,
41
+ Ownership: func(task.Ticket) bool { return true },
42
+ }
43
+ }
44
+
45
+ func ownedBinding(w *workflow.Workflow, match, ownership func(task.Ticket) bool) repo.WorkflowBinding {
46
+ return repo.WorkflowBinding{Workflow: w, Match: match, Ownership: ownership}
40
47
  }
41
48
 
42
49
  func repoWith(bindings ...repo.WorkflowBinding) *repo.Repo {
@@ -87,6 +94,53 @@ func TestUnknownClaimInvalid(t *testing.T) {
87
94
  }
88
95
  }
89
96
 
97
+ func TestClaimOwnerMismatchSkipsLifecycleMatcher(t *testing.T) {
98
+ lifecycleCalled := false
99
+ r := repoWith(ownedBinding(wf("basicFlow"), func(task.Ticket) bool {
100
+ lifecycleCalled = true
101
+ return false
102
+ }, func(task.Ticket) bool {
103
+ return false
104
+ }))
105
+ ticket := task.Ticket{
106
+ Key: "PAY-101",
107
+ WorkflowClaims: []string{"wf:basicFlow"},
108
+ Fields: map[string]any{"assignee": "bob@example.com", "status": "In Progress"},
109
+ }
110
+
111
+ _, err := router.ResolveWorkflow(r, ticket)
112
+ var mismatch *router.ClaimOwnerMismatchError
113
+ if !errors.As(err, &mismatch) || !errors.Is(err, router.ErrClaimOwnerMismatch) {
114
+ t.Fatalf("err = %v, want ClaimOwnerMismatchError", err)
115
+ }
116
+ if lifecycleCalled {
117
+ t.Fatal("claimed route evaluated the complete lifecycle matcher")
118
+ }
119
+ }
120
+
121
+ func TestClaimOwnerMatchBypassesChangedLifecycleFields(t *testing.T) {
122
+ lifecycleCalled := false
123
+ r := repoWith(ownedBinding(wf("basicFlow"), func(task.Ticket) bool {
124
+ lifecycleCalled = true
125
+ return false
126
+ }, func(ticket task.Ticket) bool {
127
+ return ticket.Fields["assignee"] == "alice@example.com"
128
+ }))
129
+ ticket := task.Ticket{
130
+ Key: "PAY-101",
131
+ WorkflowClaims: []string{"wf:basicFlow"},
132
+ Fields: map[string]any{"assignee": "alice@example.com", "status": "In Progress"},
133
+ }
134
+
135
+ got, err := router.ResolveWorkflow(r, ticket)
136
+ if err != nil || got.Name != "basicFlow" {
137
+ t.Fatalf("ResolveWorkflow = %v, %v; want basicFlow", got, err)
138
+ }
139
+ if lifecycleCalled {
140
+ t.Fatal("claimed route evaluated the complete lifecycle matcher")
141
+ }
142
+ }
143
+
90
144
  func TestClaimForWorkflowNotBoundToRepo(t *testing.T) {
91
145
  // otherFlow exists but targets another repo, so it is not in this
92
146
  // repo's bindings.
@@ -158,6 +212,18 @@ func (routingTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool
158
212
  return func(task.Ticket) bool { return true }, nil
159
213
  }
160
214
 
215
+ func (routingTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
216
+ return func(task.Ticket) bool { return true }, nil
217
+ }
218
+
219
+ func (routingTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
220
+ return nil
221
+ }
222
+
223
+ func (routingTaskSystem) ClaimIfOwned(context.Context, task.TicketRef, string, config.RawValues) error {
224
+ return nil
225
+ }
226
+
161
227
  func TestPollerRoutesWhileBindingsAreReplaced(t *testing.T) {
162
228
  registry := repo.NewRegistry()
163
229
  registered := &repo.Repo{Name: "payments", TaskSystem: routingTaskSystem{}}
@@ -2,6 +2,7 @@ package run
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
  "fmt"
6
7
  "log/slog"
7
8
  "sync"
@@ -62,7 +63,10 @@ func newerRun(candidate, current Run) bool {
62
63
  // is already assigned to this workflow, reuses the newest active execution
63
64
  // attempt, checks the stable logical cancellation marker before recreating a
64
65
  // missing claimed run, then ensures the durable run with a value snapshot of
65
- // the workflow.
66
+ // the workflow. If an already-running ticket is later reassigned, automatic
67
+ // polling keeps the original claim/run with its original owner and refuses
68
+ // to reconcile it; ownership transfer is an explicit operator action, never
69
+ // an automatic duplicate run or cross-database handoff.
66
70
  func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.Workflow, ticket task.Ticket) error {
67
71
  if rp == nil {
68
72
  return fmt.Errorf("ensure run: repository is unavailable")
@@ -76,6 +80,10 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
76
80
  if wf == nil {
77
81
  return fmt.Errorf("ensure run repo %q: workflow is unavailable", rp.Name)
78
82
  }
83
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
84
+ if !ok {
85
+ return fmt.Errorf("ensure run repo %q: task system lacks required ownership capabilities", rp.Name)
86
+ }
79
87
  if m.Gate != nil {
80
88
  m.Gate.Lock()
81
89
  defer m.Gate.Unlock()
@@ -115,6 +123,40 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
115
123
  }
116
124
  }
117
125
 
126
+ // Router ownership is evaluated from the poll snapshot. Revalidate a
127
+ // claimed ticket through the adapter before any run lookup, cancellation
128
+ // marker check, or durable execution creation so a reassignment cannot
129
+ // reach the claim-before-run recovery path. Explicit run restart is a
130
+ // separate operator-authorized path and does not use this automatic gate.
131
+ if claimed {
132
+ // Re-apply the published poll-snapshot matcher as a guard for direct
133
+ // callers that bypass the router. The adapter re-read below remains the
134
+ // race-closing check for provider state that changed after Poll.
135
+ for _, binding := range rp.Bindings() {
136
+ if binding.Workflow == nil || binding.Workflow.Name != wf.Name || binding.Ownership == nil {
137
+ continue
138
+ }
139
+ if !binding.Ownership(ticket) {
140
+ err := &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: wf.Name}
141
+ slog.Info("ensure-run outcome",
142
+ "ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name,
143
+ "runID", string(id), "outcome", "claim-owner-mismatch", "stage", "ownership", "error", err)
144
+ return fmt.Errorf("validate ownership for claimed ticket %s: %w", ticket.Key, err)
145
+ }
146
+ break
147
+ }
148
+ if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), wf.Name, wf.TaskConfig); err != nil {
149
+ outcome := "ownership-error"
150
+ if errors.Is(err, task.ErrOwnershipMismatch) {
151
+ outcome = "claim-owner-mismatch"
152
+ }
153
+ slog.Info("ensure-run outcome",
154
+ "ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name,
155
+ "runID", string(id), "outcome", outcome, "stage", "ownership", "error", err)
156
+ return fmt.Errorf("validate ownership for claimed ticket %s: %w", ticket.Key, err)
157
+ }
158
+ }
159
+
118
160
  var candidates []Run
119
161
  if m.Runs != nil {
120
162
  var err error
@@ -160,10 +202,15 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
160
202
  }
161
203
 
162
204
  if !claimed {
163
- if err := rp.TaskSystem.Claim(ctx, ticket.Ref(), wf.Name); err != nil {
205
+ err := capabilities.ClaimIfOwned(ctx, ticket.Ref(), wf.Name, wf.TaskConfig)
206
+ if err != nil {
207
+ outcome := "error"
208
+ if errors.Is(err, task.ErrOwnershipMismatch) {
209
+ outcome = "claim-owner-mismatch"
210
+ }
164
211
  slog.Info("ensure-run outcome",
165
212
  "ticket", ticket.Key, "repo", rp.Name, "workflow", wf.Name, "runID", string(id),
166
- "outcome", "error", "stage", "claim", "error", err)
213
+ "outcome", outcome, "stage", "claim", "error", err)
167
214
  return fmt.Errorf("claim %s for workflow %s: %w", ticket.Key, wf.Name, err)
168
215
  }
169
216
  } else {
@@ -210,6 +257,46 @@ func (m *RunManager) ensure(ctx context.Context, start Start) error {
210
257
  return nil
211
258
  }
212
259
 
260
+ // BackfillClaimOwner performs an explicit provider-side repair for a legacy
261
+ // workflow claim. It only adds adapter-owned provenance; it never routes,
262
+ // starts, cancels, or recreates a run and never removes the wf: claim.
263
+ func (m *RunManager) BackfillClaimOwner(ctx context.Context, repoName, ticket, workflowName string) error {
264
+ if m.Gate != nil {
265
+ m.Gate.Lock()
266
+ defer m.Gate.Unlock()
267
+ }
268
+ if m.Repos == nil || m.Workflows == nil {
269
+ return fmt.Errorf("backfill claim owner: repository/workflow registries are not configured")
270
+ }
271
+ rp, ok := m.Repos.Get(repoName)
272
+ if !ok {
273
+ return fmt.Errorf("backfill claim owner: repo %q is not registered", repoName)
274
+ }
275
+ wf, ok := m.Workflows.Get(workflowName)
276
+ if !ok {
277
+ return fmt.Errorf("backfill claim owner: workflow %q is not stored", workflowName)
278
+ }
279
+ bound := false
280
+ for _, name := range wf.Repos {
281
+ if name == repoName {
282
+ bound = true
283
+ break
284
+ }
285
+ }
286
+ if !bound {
287
+ return fmt.Errorf("backfill claim owner: workflow %q does not target repo %q", workflowName, repoName)
288
+ }
289
+ backfiller, ok := rp.TaskSystem.(task.ClaimOwnerBackfiller)
290
+ if !ok {
291
+ return fmt.Errorf("backfill claim owner: task system for repo %q does not support explicit ownership backfill", repoName)
292
+ }
293
+ if err := backfiller.BackfillClaimOwner(ctx, task.TicketRef{Key: ticket}, workflowName, wf.TaskConfig); err != nil {
294
+ return fmt.Errorf("backfill claim owner for %s: %w", ticket, err)
295
+ }
296
+ slog.Info("claim-owner backfill", "repo", repoName, "ticket", ticket, "workflow", workflowName, "outcome", "provenance-added")
297
+ return nil
298
+ }
299
+
213
300
  // RestartByTicket creates or returns the one active fresh attempt for a
214
301
  // canceled ticket. It resolves the repo and latest workflow from the current
215
302
  // registries, so the new durable snapshot is never copied from the canceled
@@ -6,6 +6,7 @@ import (
6
6
  "strings"
7
7
  "testing"
8
8
 
9
+ "github.com/rajpopat27/relay-flow/internal/config"
9
10
  "github.com/rajpopat27/relay-flow/internal/identity"
10
11
  "github.com/rajpopat27/relay-flow/internal/repo"
11
12
  "github.com/rajpopat27/relay-flow/internal/run"
@@ -52,12 +53,33 @@ func (s *recordingSystem) Claim(_ context.Context, ref task.TicketRef, wf string
52
53
  return s.claimErr
53
54
  }
54
55
 
56
+ func (s *recordingSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
57
+ return func(task.Ticket) bool { return true }, nil
58
+ }
59
+
60
+ func (s *recordingSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
61
+ return nil
62
+ }
63
+
64
+ func (s *recordingSystem) ClaimIfOwned(ctx context.Context, ref task.TicketRef, wf string, _ config.RawValues) error {
65
+ return s.Claim(ctx, ref, wf)
66
+ }
67
+
55
68
  func (s *recordingSystem) HasComment(_ context.Context, _ task.Target, marker string) (bool, error) {
56
69
  s.hasCommentN++
57
70
  s.markers = append(s.markers, marker)
58
71
  return s.hasCancel, nil
59
72
  }
60
73
 
74
+ type ownershipRecordingSystem struct {
75
+ *recordingSystem
76
+ ownershipErr error
77
+ }
78
+
79
+ func (s *ownershipRecordingSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
80
+ return s.ownershipErr
81
+ }
82
+
61
83
  // fakeExecutor records EnsureRun calls into the shared log.
62
84
  type fakeExecutor struct {
63
85
  log *eventLog
@@ -243,6 +265,26 @@ func TestCancellationMarkerSkipsRunCreation(t *testing.T) {
243
265
  }
244
266
  }
245
267
 
268
+ func TestMismatchedClaimStopsBeforeCancellationOrRunCreation(t *testing.T) {
269
+ log := newEventLog()
270
+ base := &recordingSystem{log: log, hasCancel: true}
271
+ sys := &ownershipRecordingSystem{
272
+ recordingSystem: base,
273
+ ownershipErr: &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"},
274
+ }
275
+ exec := &fakeExecutor{log: log}
276
+ m := &run.RunManager{Executor: exec, Runs: &fakeQueries{byTicket: map[string]run.Run{}}}
277
+ ticket := task.Ticket{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
278
+
279
+ err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket)
280
+ if !errors.Is(err, task.ErrOwnershipMismatch) {
281
+ t.Fatalf("EnsureRun error = %v, want ownership mismatch", err)
282
+ }
283
+ if base.hasCommentN != 0 || len(exec.ensures) != 0 {
284
+ t.Fatalf("mismatched owner reached recovery/run creation: comments=%d ensures=%d", base.hasCommentN, len(exec.ensures))
285
+ }
286
+ }
287
+
246
288
  func TestDeterministicRunID(t *testing.T) {
247
289
  log := newEventLog()
248
290
  sys := &recordingSystem{log: log}
@@ -360,6 +402,38 @@ func TestRestartByTicketCreatesNumericFreshAttempt(t *testing.T) {
360
402
  }
361
403
  }
362
404
 
405
+ func TestRestartByTicketRemainsExplicitOperatorOwnerBypass(t *testing.T) {
406
+ log := newEventLog()
407
+ base := &recordingSystem{log: log}
408
+ sys := &ownershipRecordingSystem{
409
+ recordingSystem: base,
410
+ ownershipErr: &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"},
411
+ }
412
+ wf := testWorkflow("basicFlow")
413
+ previous := run.Run{
414
+ ID: identity.NewRunID("payments", wf.Name, "PAY-101"),
415
+ Repo: "payments", Workflow: wf.Name,
416
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"}, State: run.StateCanceled,
417
+ }
418
+ queries := &fakeQueries{
419
+ byTicket: map[string]run.Run{"PAY-101": previous},
420
+ list: []run.Run{previous},
421
+ }
422
+ exec := &fakeExecutor{log: log, created: true}
423
+ repos := repo.NewRegistry()
424
+ repos.Replace(testRepo(sys))
425
+ workflows := &workflow.Registry{}
426
+ workflows.Replace(wf)
427
+ m := &run.RunManager{Executor: exec, Runs: queries, Repos: repos, Workflows: workflows}
428
+
429
+ if _, err := m.RestartByTicket(context.Background(), "PAY-101"); err != nil {
430
+ t.Fatalf("explicit restart was blocked by automatic ownership gate: %v", err)
431
+ }
432
+ if len(exec.ensures) != 1 {
433
+ t.Fatalf("explicit restart ensured %d runs, want 1", len(exec.ensures))
434
+ }
435
+ }
436
+
363
437
  func TestRestartByTicketIsIdempotentForActiveFreshAttempt(t *testing.T) {
364
438
  attempt := run.Run{
365
439
  ID: "payments/basicFlow/PAY-101~attempt~2", LogicalID: "payments/basicFlow/PAY-101", AttemptID: 2,
@@ -95,6 +95,19 @@ func TestErrorEnvelopeAndStatusMapping(t *testing.T) {
95
95
  }
96
96
  }
97
97
 
98
+ func TestLegacyClaimBackfillEndpointIsExplicit(t *testing.T) {
99
+ fake := &fakeServices{}
100
+ c, cleanup := startHandler(t, fake)
101
+ defer cleanup()
102
+ code, env := do(t, c, http.MethodPost, "http://relay/runs/by-ticket/PAY-101/backfill-owner", []byte(`{"repo":"payments","workflow":"basicFlow"}`))
103
+ if code != http.StatusOK || !env.OK {
104
+ t.Fatalf("backfill endpoint: code=%d env=%+v, want 200 ok", code, env)
105
+ }
106
+ if len(fake.backfills) != 1 || fake.backfills[0] != "payments:PAY-101:basicFlow" {
107
+ t.Fatalf("backfill calls = %v, want one explicit provider repair", fake.backfills)
108
+ }
109
+ }
110
+
98
111
  func TestWorkflowConflictMapsTo409(t *testing.T) {
99
112
  // basicFlow is seeded with an active run, so replacement conflicts -> 409.
100
113
  c, cleanup := startHandler(t, &fakeServices{activeWorkflows: map[string]bool{"basicFlow": true}})
@@ -281,6 +281,13 @@ func (c *Client) CancelRun(ctx context.Context, ticket, reason string) error {
281
281
  return c.call(ctx, http.MethodPost, "/runs/by-ticket/"+url.PathEscape(ticket)+"/cancel", payload, nil)
282
282
  }
283
283
 
284
+ // BackfillClaimOwner explicitly repairs a legacy workflow claim by asking the
285
+ // selected repo adapter to add provenance without changing the claim.
286
+ func (c *Client) BackfillClaimOwner(ctx context.Context, repoName, ticket, workflow string) error {
287
+ payload, _ := json.Marshal(map[string]string{"repo": repoName, "workflow": workflow})
288
+ return c.call(ctx, http.MethodPost, "/runs/by-ticket/"+url.PathEscape(ticket)+"/backfill-owner", payload, nil)
289
+ }
290
+
284
291
  // ListRuns returns runs matching the filter.
285
292
  func (c *Client) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
286
293
  q := url.Values{}
@@ -39,6 +39,7 @@ type fakeServices struct {
39
39
  restartRun run.Run
40
40
  restartErr error
41
41
  restarts []string
42
+ backfills []string
42
43
  registrationValues []config.RawValues
43
44
  ensureRepoCalls []runner.RepoCandidate
44
45
  ensureRepoErr error
@@ -111,6 +112,10 @@ func (f *fakeServices) CancelRun(_ context.Context, ticket, _ string) error {
111
112
  }
112
113
  return errNotFound{ticket}
113
114
  }
115
+ func (f *fakeServices) BackfillClaimOwner(_ context.Context, repoName, ticket, workflow string) error {
116
+ f.backfills = append(f.backfills, repoName+":"+ticket+":"+workflow)
117
+ return nil
118
+ }
114
119
 
115
120
  func (f *fakeServices) SubmitReport(ctx context.Context, _ run.ReportRequest) (run.ReportAck, error) {
116
121
  f.submittedReports++