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.
- package/README.md +27 -19
- package/cmd/relay-flow/commands_test.go +440 -21
- package/cmd/relay-flow/main.go +203 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/internal/execution/goworkflows/activities.go +11 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +30 -1
- package/internal/execution/goworkflows/interpreter.go +18 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +563 -1
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/interpreter.go +2 -1
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +1 -0
- package/internal/harness/opencode/opencode.go +5 -0
- package/internal/harness/opencode/opencode_test.go +17 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +6 -0
- package/internal/harness/pi/task_env_test.go +16 -0
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +228 -4
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +66 -0
- package/internal/workflow/report.go +3 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- 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
|
|
48
|
-
// unknown or unbound claim is invalid; otherwise
|
|
49
|
-
// zero matches is ErrNoMatch, one match
|
|
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
|
|
60
|
-
|
|
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{
|
|
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{}}
|
package/internal/run/manager.go
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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}
|
|
584
|
-
// /runs/by-ticket/{key}/restart
|
|
585
|
-
// /runs/by-ticket/{key}/cancel
|
|
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
|
}
|