relay-flow 0.3.8-alpha → 0.3.10-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 (52) hide show
  1. package/README.md +27 -19
  2. package/cmd/relay-flow/commands_test.go +440 -21
  3. package/cmd/relay-flow/main.go +203 -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/internal/execution/goworkflows/activities.go +11 -0
  10. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  11. package/internal/execution/goworkflows/engine.go +278 -9
  12. package/internal/execution/goworkflows/fakes_test.go +30 -1
  13. package/internal/execution/goworkflows/interpreter.go +18 -1
  14. package/internal/execution/goworkflows/ownership_test.go +259 -0
  15. package/internal/execution/goworkflows/projection.go +8 -0
  16. package/internal/execution/goworkflows/recovery_test.go +563 -1
  17. package/internal/execution/projection/detail_test.go +38 -0
  18. package/internal/execution/projection/projection.go +80 -15
  19. package/internal/execution/temporal/interpreter.go +2 -1
  20. package/internal/execution/temporal/recovery.go +68 -36
  21. package/internal/harness/harness.go +1 -0
  22. package/internal/harness/opencode/opencode.go +5 -0
  23. package/internal/harness/opencode/opencode_test.go +17 -1
  24. package/internal/harness/opencode/repo_setup.go +1 -1
  25. package/internal/harness/pi/pi.go +6 -0
  26. package/internal/harness/pi/task_env_test.go +16 -0
  27. package/internal/recover/recover.go +30 -1
  28. package/internal/repo/binding_test.go +63 -0
  29. package/internal/repo/repo.go +47 -6
  30. package/internal/router/router.go +39 -5
  31. package/internal/router/router_test.go +67 -1
  32. package/internal/run/manager.go +90 -3
  33. package/internal/run/run_manager_test.go +74 -0
  34. package/internal/server/api_test.go +13 -0
  35. package/internal/server/client.go +7 -0
  36. package/internal/server/fixture_test.go +5 -0
  37. package/internal/server/server.go +32 -3
  38. package/internal/task/beads/beads.go +201 -3
  39. package/internal/task/beads/beads_test.go +172 -0
  40. package/internal/task/jira/effects_test.go +86 -0
  41. package/internal/task/jira/filters_test.go +103 -0
  42. package/internal/task/jira/helpers_test.go +11 -2
  43. package/internal/task/jira/jira.go +228 -4
  44. package/internal/task/jira/normalize.go +30 -15
  45. package/internal/task/jira/rest/client.go +17 -2
  46. package/internal/task/jira/transition_defaults_test.go +3 -1
  47. package/internal/task/task.go +66 -0
  48. package/internal/workflow/report.go +3 -0
  49. package/internal/workflow/report_test.go +16 -0
  50. package/internal/workflow/workflow.go +24 -0
  51. package/internal/workflow/workflow_test.go +58 -1
  52. package/package.json +1 -1
@@ -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++
@@ -35,6 +35,7 @@ type Deps interface {
35
35
  GetRunByTicket(ctx context.Context, ticket string) (run.Run, error)
36
36
  RestartRun(ctx context.Context, ticket string) (run.Run, error)
37
37
  CancelRun(ctx context.Context, ticket, reason string) error
38
+ BackfillClaimOwner(ctx context.Context, repo, ticket, workflow string) error
38
39
 
39
40
  // Reports
40
41
  HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error)
@@ -580,9 +581,10 @@ func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
580
581
  }
581
582
 
582
583
  func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
583
- // /runs/by-ticket/{key} GET
584
- // /runs/by-ticket/{key}/restart POST
585
- // /runs/by-ticket/{key}/cancel POST
584
+ // /runs/by-ticket/{key} GET
585
+ // /runs/by-ticket/{key}/restart POST
586
+ // /runs/by-ticket/{key}/cancel POST
587
+ // /runs/by-ticket/{key}/backfill-owner POST
586
588
  rest := strings.TrimPrefix(r.URL.Path, "/runs/by-ticket/")
587
589
  parts := strings.Split(rest, "/")
588
590
  if len(parts) == 1 && parts[0] != "" {
@@ -643,5 +645,32 @@ func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
643
645
  writeOK(w, http.StatusOK, map[string]string{"canceled": parts[0]})
644
646
  return
645
647
  }
648
+ if len(parts) == 2 && parts[0] != "" && parts[1] == "backfill-owner" {
649
+ if !methodOnly(w, r, http.MethodPost) {
650
+ return
651
+ }
652
+ var payload struct {
653
+ Repo string `json:"repo"`
654
+ Workflow string `json:"workflow"`
655
+ }
656
+ body, err := readBody(r)
657
+ if err != nil {
658
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
659
+ return
660
+ }
661
+ if err := decodeStrict(body, &payload); err != nil || strings.TrimSpace(payload.Repo) == "" || strings.TrimSpace(payload.Workflow) == "" {
662
+ if err == nil {
663
+ err = errors.New("repo and workflow are required")
664
+ }
665
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
666
+ return
667
+ }
668
+ if err := s.deps.BackfillClaimOwner(r.Context(), payload.Repo, parts[0], payload.Workflow); err != nil {
669
+ mapErr(w, err)
670
+ return
671
+ }
672
+ writeOK(w, http.StatusOK, map[string]string{"backfilled": parts[0], "repo": payload.Repo, "workflow": payload.Workflow})
673
+ return
674
+ }
646
675
  writeErr(w, http.StatusNotFound, "notFound", "run not found")
647
676
  }