relay-flow 0.3.9-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/fakes_test.go +20 -0
- package/internal/execution/goworkflows/interpreter.go +1 -0
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/recovery_test.go +81 -1
- 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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
package goworkflows_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
13
|
+
recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/router"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// ownershipCompositionState models the provider-owned labels and current
|
|
21
|
+
// assignee shared by two independent relay-flow databases. The adapter fake
|
|
22
|
+
// persists a durable wf-owner marker when the first server claims the ticket.
|
|
23
|
+
type ownershipCompositionState struct {
|
|
24
|
+
mu sync.Mutex
|
|
25
|
+
assignee string
|
|
26
|
+
labels []string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ownershipCompositionTaskSystem struct {
|
|
30
|
+
*fakeTaskSystem
|
|
31
|
+
owner string
|
|
32
|
+
state *ownershipCompositionState
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func newOwnershipCompositionTaskSystem(owner string, state *ownershipCompositionState, log *eventLog) *ownershipCompositionTaskSystem {
|
|
36
|
+
return &ownershipCompositionTaskSystem{
|
|
37
|
+
fakeTaskSystem: newFakeTaskSystem(log), owner: owner, state: state,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (s *ownershipCompositionTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
42
|
+
s.state.mu.Lock()
|
|
43
|
+
defer s.state.mu.Unlock()
|
|
44
|
+
claims := make([]string, 0, 1)
|
|
45
|
+
for _, label := range s.state.labels {
|
|
46
|
+
if len(label) > len("wf:") && label[:len("wf:")] == "wf:" {
|
|
47
|
+
claims = append(claims, label)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return []task.Ticket{{
|
|
51
|
+
ID: "1", Key: "PAY-101", Title: "ownership composition",
|
|
52
|
+
WorkflowClaims: claims,
|
|
53
|
+
Fields: map[string]any{
|
|
54
|
+
"assignee": s.state.assignee,
|
|
55
|
+
"labels": append([]string(nil), s.state.labels...),
|
|
56
|
+
},
|
|
57
|
+
}}, nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (s *ownershipCompositionTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
61
|
+
return func(ticket task.Ticket) bool {
|
|
62
|
+
assignee, _ := ticket.Fields["assignee"].(string)
|
|
63
|
+
return assignee == s.owner
|
|
64
|
+
}, nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func (s *ownershipCompositionTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
68
|
+
return func(ticket task.Ticket) bool {
|
|
69
|
+
if len(ticket.WorkflowClaims) != 1 {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
assignee, _ := ticket.Fields["assignee"].(string)
|
|
73
|
+
if assignee != s.owner {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
want := "wf-owner:" + ticket.WorkflowClaims[0][len("wf:"):] + ":" + s.owner
|
|
77
|
+
labels, _ := ticket.Fields["labels"].([]string)
|
|
78
|
+
for _, label := range labels {
|
|
79
|
+
if label == want {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false
|
|
84
|
+
}, nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func (s *ownershipCompositionTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
88
|
+
s.state.mu.Lock()
|
|
89
|
+
defer s.state.mu.Unlock()
|
|
90
|
+
if s.hasOwnerMarkerLocked("ownershipFlow") {
|
|
91
|
+
return nil
|
|
92
|
+
}
|
|
93
|
+
return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "ownershipFlow"}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func (s *ownershipCompositionTaskSystem) BackfillClaimOwner(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
97
|
+
s.state.mu.Lock()
|
|
98
|
+
defer s.state.mu.Unlock()
|
|
99
|
+
if !containsLabel(s.state.labels, "wf:ownershipFlow") {
|
|
100
|
+
return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "ownershipFlow"}
|
|
101
|
+
}
|
|
102
|
+
marker := "wf-owner:ownershipFlow:" + s.owner
|
|
103
|
+
if !containsLabel(s.state.labels, marker) {
|
|
104
|
+
s.state.labels = append(s.state.labels, marker)
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func (s *ownershipCompositionTaskSystem) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflowName string, _ config.RawValues) error {
|
|
110
|
+
s.state.mu.Lock()
|
|
111
|
+
defer s.state.mu.Unlock()
|
|
112
|
+
if s.state.assignee != s.owner {
|
|
113
|
+
return &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: workflowName}
|
|
114
|
+
}
|
|
115
|
+
ownerMarker := "wf-owner:" + workflowName + ":" + s.owner
|
|
116
|
+
claim := "wf:" + workflowName
|
|
117
|
+
for _, label := range s.state.labels {
|
|
118
|
+
if label == ownerMarker && containsLabel(s.state.labels, claim) {
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
if len(label) > len("wf:") && label[:len("wf:")] == "wf:" {
|
|
122
|
+
return &task.OwnershipMismatchError{Ticket: ticket.Key, Workflow: workflowName}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
s.state.labels = append(s.state.labels, ownerMarker, claim)
|
|
126
|
+
return nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func (s *ownershipCompositionTaskSystem) hasOwnerMarkerLocked(workflowName string) bool {
|
|
130
|
+
return containsLabel(s.state.labels, "wf-owner:"+workflowName+":"+s.owner)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func containsLabel(labels []string, want string) bool {
|
|
134
|
+
for _, label := range labels {
|
|
135
|
+
if label == want {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func TestExplicitLegacyBackfillPreservesClaimAndEnablesRoutingAndRecovery(t *testing.T) {
|
|
143
|
+
state := &ownershipCompositionState{assignee: "alice", labels: []string{"wf:ownershipFlow"}}
|
|
144
|
+
log := newEventLog()
|
|
145
|
+
sys := newOwnershipCompositionTaskSystem("alice", state, log)
|
|
146
|
+
reg := repoRegistryWith("payments", sys)
|
|
147
|
+
wf := linearWorkflow(false)
|
|
148
|
+
wf.Name = "ownershipFlow"
|
|
149
|
+
wf.TaskConfig = config.RawValues{"filters": map[string]any{"assignees": []any{"currentUser()"}}}
|
|
150
|
+
if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
|
|
151
|
+
t.Fatal(err)
|
|
152
|
+
}
|
|
153
|
+
workflowReg := &workflow.Registry{}
|
|
154
|
+
workflowReg.Replace(&wf)
|
|
155
|
+
fr := newFakeRunner(log)
|
|
156
|
+
engine := newEngine(t, goworkflows.Dependencies{Repos: reg, Runner: fr, Harness: newFakeHarness(log)})
|
|
157
|
+
manager := &run.RunManager{Executor: engine, Runs: engine, Repos: reg, Workflows: workflowReg}
|
|
158
|
+
if err := manager.BackfillClaimOwner(context.Background(), "payments", "PAY-101", "ownershipFlow"); err != nil {
|
|
159
|
+
t.Fatalf("explicit backfill failed: %v", err)
|
|
160
|
+
}
|
|
161
|
+
state.mu.Lock()
|
|
162
|
+
labels := append([]string(nil), state.labels...)
|
|
163
|
+
state.mu.Unlock()
|
|
164
|
+
if !containsLabel(labels, "wf:ownershipFlow") || !containsLabel(labels, "wf-owner:ownershipFlow:alice") || len(labels) != 2 {
|
|
165
|
+
t.Fatalf("backfill labels = %v, want preserved claim plus one provenance marker", labels)
|
|
166
|
+
}
|
|
167
|
+
rp, _ := reg.Get("payments")
|
|
168
|
+
ticket, _ := sys.Poll(context.Background())
|
|
169
|
+
if _, err := router.ResolveWorkflow(rp, ticket[0]); err != nil {
|
|
170
|
+
t.Fatalf("backfilled claim did not route normally: %v", err)
|
|
171
|
+
}
|
|
172
|
+
specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
173
|
+
return goworkflows.RenderMailboxSpecs(system, work, w)
|
|
174
|
+
}
|
|
175
|
+
if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, manager, specsFor); err != nil {
|
|
176
|
+
t.Fatalf("backfilled claim did not recover: %v", err)
|
|
177
|
+
}
|
|
178
|
+
if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 1 {
|
|
179
|
+
t.Fatalf("recovered runs = %v, %v; want one run", runs, err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func TestIndependentRunDatabasesCannotTakeOverReassignedClaim(t *testing.T) {
|
|
184
|
+
state := &ownershipCompositionState{assignee: "alice"}
|
|
185
|
+
logA, logB := newEventLog(), newEventLog()
|
|
186
|
+
sysA := newOwnershipCompositionTaskSystem("alice", state, logA)
|
|
187
|
+
sysB := newOwnershipCompositionTaskSystem("bob", state, logB)
|
|
188
|
+
regA := repoRegistryWith("payments", sysA)
|
|
189
|
+
regB := repoRegistryWith("payments", sysB)
|
|
190
|
+
wf := linearWorkflow(false)
|
|
191
|
+
wf.Name = "ownershipFlow"
|
|
192
|
+
wf.TaskConfig = config.RawValues{"filters": map[string]any{
|
|
193
|
+
"assignees": []any{"currentUser()"},
|
|
194
|
+
}}
|
|
195
|
+
if err := regA.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
if err := regB.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
|
|
199
|
+
t.Fatal(err)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
engineA := newEngine(t, goworkflows.Dependencies{
|
|
203
|
+
Repos: regA, Runner: newFakeRunner(logA), Harness: newFakeHarness(logA),
|
|
204
|
+
})
|
|
205
|
+
engineB := newEngine(t, goworkflows.Dependencies{
|
|
206
|
+
Repos: regB, Runner: newFakeRunner(logB), Harness: newFakeHarness(logB),
|
|
207
|
+
})
|
|
208
|
+
managerA := &run.RunManager{Executor: engineA, Runs: engineA}
|
|
209
|
+
managerB := &run.RunManager{Executor: engineB, Runs: engineB}
|
|
210
|
+
|
|
211
|
+
ticketA, err := sysA.Poll(context.Background())
|
|
212
|
+
if err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
rpA, _ := regA.Get("payments")
|
|
216
|
+
resolvedA, err := router.ResolveWorkflow(rpA, ticketA[0])
|
|
217
|
+
if err != nil {
|
|
218
|
+
t.Fatalf("owner A route failed: %v", err)
|
|
219
|
+
}
|
|
220
|
+
if err := managerA.EnsureRun(context.Background(), rpA, resolvedA, ticketA[0]); err != nil {
|
|
221
|
+
t.Fatalf("owner A EnsureRun failed: %v", err)
|
|
222
|
+
}
|
|
223
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
224
|
+
current, err := engineA.GetRun(context.Background(), identity.NewRunID("payments", "ownershipFlow", "PAY-101"))
|
|
225
|
+
return err == nil && current.State != run.StateCompleted && current.State != run.StateCanceled
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
state.mu.Lock()
|
|
229
|
+
state.assignee = "bob"
|
|
230
|
+
state.mu.Unlock()
|
|
231
|
+
ticketB, err := sysB.Poll(context.Background())
|
|
232
|
+
if err != nil {
|
|
233
|
+
t.Fatal(err)
|
|
234
|
+
}
|
|
235
|
+
rpB, _ := regB.Get("payments")
|
|
236
|
+
if _, err := router.ResolveWorkflow(rpB, ticketB[0]); !errors.Is(err, router.ErrClaimOwnerMismatch) {
|
|
237
|
+
t.Fatalf("owner B route error = %v, want claim-owner-mismatch", err)
|
|
238
|
+
}
|
|
239
|
+
if err := managerB.EnsureRun(context.Background(), rpB, &wf, ticketB[0]); !errors.Is(err, task.ErrOwnershipMismatch) {
|
|
240
|
+
t.Fatalf("owner B EnsureRun error = %v, want ownership mismatch", err)
|
|
241
|
+
}
|
|
242
|
+
if runs, err := engineB.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
|
|
243
|
+
t.Fatalf("owner B database runs = %v, %v; want none", runs, err)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Reassignment is a current-owner mismatch for both servers. The
|
|
247
|
+
// original durable run remains in database A, but neither server calls
|
|
248
|
+
// EnsureRun to reconcile or create execution state after the mismatch.
|
|
249
|
+
ticketAAfterReassignment, _ := sysA.Poll(context.Background())
|
|
250
|
+
if _, err := router.ResolveWorkflow(rpA, ticketAAfterReassignment[0]); !errors.Is(err, router.ErrClaimOwnerMismatch) {
|
|
251
|
+
t.Fatalf("owner A route after reassignment = %v, want claim-owner-mismatch", err)
|
|
252
|
+
}
|
|
253
|
+
if err := managerA.EnsureRun(context.Background(), rpA, &wf, ticketAAfterReassignment[0]); !errors.Is(err, task.ErrOwnershipMismatch) {
|
|
254
|
+
t.Fatalf("owner A EnsureRun after reassignment = %v, want ownership mismatch", err)
|
|
255
|
+
}
|
|
256
|
+
if runs, err := engineA.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "ownershipFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 1 {
|
|
257
|
+
t.Fatalf("owner A database runs after reassignment = %v, %v; want one existing run", runs, err)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -10,6 +10,7 @@ import (
|
|
|
10
10
|
"time"
|
|
11
11
|
|
|
12
12
|
"github.com/cschleiden/go-workflows/backend/history"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
14
|
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
14
15
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
15
16
|
recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
|
|
@@ -1358,7 +1359,7 @@ func TestDatabaseFileIsOwnerOnly(t *testing.T) {
|
|
|
1358
1359
|
// task.System/runner.Runner plus the real engine and the real RunManager.
|
|
1359
1360
|
// Rewritten in 6.3 from a test-local copy so the production wiring is
|
|
1360
1361
|
// actually covered.
|
|
1361
|
-
func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys
|
|
1362
|
+
func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys task.System, fr *fakeRunner, wf workflow.Workflow) error {
|
|
1362
1363
|
reg := repo.NewRegistry()
|
|
1363
1364
|
reg.Replace(&repo.Repo{
|
|
1364
1365
|
Name: "payments",
|
|
@@ -1376,6 +1377,85 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
|
|
|
1376
1377
|
return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
|
|
1377
1378
|
}
|
|
1378
1379
|
|
|
1380
|
+
type mismatchedRecoveryTaskSystem struct{ *fakeTaskSystem }
|
|
1381
|
+
|
|
1382
|
+
type legacyRecoveryTaskSystem struct{ *fakeTaskSystem }
|
|
1383
|
+
|
|
1384
|
+
func (s *legacyRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
1385
|
+
return func(task.Ticket) bool { return false }, nil
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
func (s *mismatchedRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
1389
|
+
// Let the router resolve the claim; the current-provider read below is the
|
|
1390
|
+
// mismatch gate being exercised by this recovery regression.
|
|
1391
|
+
return func(task.Ticket) bool { return true }, nil
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
func (s *mismatchedRecoveryTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
1395
|
+
return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
func TestServeRecoverSkipsOwnerMismatchBeforeMutations(t *testing.T) {
|
|
1399
|
+
log := newEventLog()
|
|
1400
|
+
base := newFakeTaskSystem(log)
|
|
1401
|
+
sys := &mismatchedRecoveryTaskSystem{fakeTaskSystem: base}
|
|
1402
|
+
sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
|
|
1403
|
+
fr := newFakeRunner(log)
|
|
1404
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
1405
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
1406
|
+
})
|
|
1407
|
+
wf := linearWorkflow(false)
|
|
1408
|
+
reg := repo.NewRegistry()
|
|
1409
|
+
reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
|
|
1410
|
+
if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
|
|
1411
|
+
t.Fatal(err)
|
|
1412
|
+
}
|
|
1413
|
+
rm := &run.RunManager{Executor: engine, Runs: engine}
|
|
1414
|
+
specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
1415
|
+
return goworkflows.RenderMailboxSpecs(system, work, w)
|
|
1416
|
+
}
|
|
1417
|
+
if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, rm, specsFor); err != nil {
|
|
1418
|
+
t.Fatalf("recovery failed on owner mismatch: %v", err)
|
|
1419
|
+
}
|
|
1420
|
+
if len(fr.closedRun) != 0 {
|
|
1421
|
+
t.Fatalf("CloseTerminals calls = %v, want none", fr.closedRun)
|
|
1422
|
+
}
|
|
1423
|
+
if base.hasCommentCount() != 0 {
|
|
1424
|
+
t.Fatalf("HasComment calls = %d, want none before owner rejection", base.hasCommentCount())
|
|
1425
|
+
}
|
|
1426
|
+
if len(base.resets) != 0 {
|
|
1427
|
+
t.Fatalf("ResetForRecovery calls = %v, want none", base.resets)
|
|
1428
|
+
}
|
|
1429
|
+
for _, event := range log.all() {
|
|
1430
|
+
if strings.HasPrefix(event, "ensureMailboxes:") || strings.HasPrefix(event, "createMailbox:") {
|
|
1431
|
+
t.Fatalf("mailbox mutation after owner mismatch: %v", log.all())
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
|
|
1435
|
+
t.Fatalf("durable runs after owner mismatch = %v, %v; want none", runs, err)
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
func TestServeRecoverSkipsLegacyClaimWithoutProvenance(t *testing.T) {
|
|
1440
|
+
log := newEventLog()
|
|
1441
|
+
base := newFakeTaskSystem(log)
|
|
1442
|
+
sys := &legacyRecoveryTaskSystem{fakeTaskSystem: base}
|
|
1443
|
+
sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
|
|
1444
|
+
fr := newFakeRunner(log)
|
|
1445
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
1446
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
1447
|
+
})
|
|
1448
|
+
if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
|
|
1449
|
+
t.Fatalf("legacy-claim recovery failed: %v", err)
|
|
1450
|
+
}
|
|
1451
|
+
if len(fr.closedRun) != 0 || base.hasCommentCount() != 0 || len(base.resets) != 0 {
|
|
1452
|
+
t.Fatalf("legacy claim caused recovery side effects: closed=%v comments=%d resets=%v", fr.closedRun, base.hasCommentCount(), base.resets)
|
|
1453
|
+
}
|
|
1454
|
+
if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
|
|
1455
|
+
t.Fatalf("legacy claim durable runs = %v, %v; want none", runs, err)
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1379
1459
|
func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
|
|
1380
1460
|
log := newEventLog()
|
|
1381
1461
|
sys := newFakeTaskSystem(log)
|
|
@@ -402,7 +402,8 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
402
402
|
spec := harness.LaunchSpec{
|
|
403
403
|
RunID: start.ID, NodeVisitID: visit, RepoName: start.Repo, RepoPath: start.RepoPath,
|
|
404
404
|
Workflow: wf.Name, Ticket: start.Ticket.Key, Node: current, NodeType: node.Type,
|
|
405
|
-
|
|
405
|
+
AutoReject: node.AutoReject,
|
|
406
|
+
Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
|
|
406
407
|
PromptData: harness.PromptData{
|
|
407
408
|
TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
|
|
408
409
|
Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
|
|
@@ -6,10 +6,12 @@ import (
|
|
|
6
6
|
"fmt"
|
|
7
7
|
"log/slog"
|
|
8
8
|
"sort"
|
|
9
|
-
"strings"
|
|
10
9
|
"time"
|
|
11
10
|
|
|
12
11
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/router"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
15
|
enumspb "go.temporal.io/api/enums/v1"
|
|
14
16
|
"go.temporal.io/api/serviceerror"
|
|
15
17
|
workflowpb "go.temporal.io/api/workflow/v1"
|
|
@@ -145,52 +147,82 @@ func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string
|
|
|
145
147
|
slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
146
148
|
continue
|
|
147
149
|
}
|
|
150
|
+
capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
|
|
151
|
+
if !ok {
|
|
152
|
+
slog.Warn("Temporal recovery skipping repository without ownership capabilities", "repo", rp.Name,
|
|
153
|
+
"outcome", "ownership-capability-missing")
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
148
156
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
149
157
|
if err != nil {
|
|
150
158
|
return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
|
|
151
159
|
}
|
|
152
160
|
for _, ticket := range tickets {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
|
|
169
|
-
if visible[string(expectedID)] {
|
|
170
|
-
continue
|
|
161
|
+
// Projection reconciliation only considers claimed parents. Resolve
|
|
162
|
+
// through the same router as normal polling so ambiguous, unknown,
|
|
163
|
+
// and owner-mismatched claims cannot restore an execution.
|
|
164
|
+
if len(ticket.WorkflowClaims) != 1 {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
wf, err := router.ResolveWorkflow(rp, ticket)
|
|
168
|
+
if err != nil {
|
|
169
|
+
var ownerMismatch *router.ClaimOwnerMismatchError
|
|
170
|
+
if errors.As(err, &ownerMismatch) {
|
|
171
|
+
slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
172
|
+
"workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch")
|
|
173
|
+
} else {
|
|
174
|
+
slog.Warn("Temporal recovery skipped claimed ticket", "repo", rp.Name, "ticket", ticket.Key, "outcome", "invalid-claim", "error", err)
|
|
171
175
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
workflowName := wf.Name
|
|
179
|
+
var matched *repo.WorkflowBinding
|
|
180
|
+
for _, binding := range rp.Bindings() {
|
|
181
|
+
if binding.Workflow != nil && binding.Workflow.Name == workflowName {
|
|
182
|
+
candidate := binding
|
|
183
|
+
matched = &candidate
|
|
184
|
+
break
|
|
183
185
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
}
|
|
187
|
+
if matched == nil {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
// Existing Temporal executions are restored only after the same
|
|
191
|
+
// ownership gate used by normal claimed routing. Lifecycle
|
|
192
|
+
// filters remain intentionally bypassed.
|
|
193
|
+
if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), workflowName, matched.Workflow.TaskConfig); err != nil {
|
|
194
|
+
if errors.Is(err, task.ErrOwnershipMismatch) {
|
|
195
|
+
slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
196
|
+
"workflow", workflowName, "outcome", "claim-owner-mismatch", "error", err)
|
|
186
197
|
continue
|
|
187
198
|
}
|
|
188
|
-
|
|
199
|
+
return fmt.Errorf("poll repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
|
|
200
|
+
}
|
|
201
|
+
expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
|
|
202
|
+
if visible[string(expectedID)] {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
|
|
206
|
+
if err != nil {
|
|
207
|
+
var notFound *serviceerror.NotFound
|
|
208
|
+
if errors.As(err, ¬Found) {
|
|
209
|
+
// This is the documented claim-before-run gap. Record it
|
|
210
|
+
// for operators, but leave creation to normal polling after
|
|
211
|
+
// recovery; this path must never start a replacement.
|
|
212
|
+
slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
|
|
189
213
|
continue
|
|
190
214
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
215
|
+
return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
|
|
216
|
+
}
|
|
217
|
+
if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
|
|
218
|
+
info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
|
|
225
|
+
return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
|
|
194
226
|
}
|
|
195
227
|
}
|
|
196
228
|
}
|
|
@@ -17,6 +17,7 @@ import (
|
|
|
17
17
|
"os/exec"
|
|
18
18
|
"path/filepath"
|
|
19
19
|
"regexp"
|
|
20
|
+
"strconv"
|
|
20
21
|
"strings"
|
|
21
22
|
|
|
22
23
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
@@ -206,6 +207,10 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
|
|
|
206
207
|
} {
|
|
207
208
|
env[key] = value
|
|
208
209
|
}
|
|
210
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
211
|
+
// HITL policy is launch metadata, not part of the report wire object.
|
|
212
|
+
env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
|
|
213
|
+
}
|
|
209
214
|
args := []string{}
|
|
210
215
|
if spec.ResumeID != "" {
|
|
211
216
|
args = append(args, "--session", spec.ResumeID)
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.3.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.10-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -57,6 +57,22 @@ func TestBuildCommandArgv(t *testing.T) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
|
|
61
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
62
|
+
cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
|
|
63
|
+
NodeType: workflow.NodeHITL,
|
|
64
|
+
AutoReject: true,
|
|
65
|
+
Agent: "reviewer",
|
|
66
|
+
Prompt: "review the ticket",
|
|
67
|
+
})
|
|
68
|
+
if err != nil {
|
|
69
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
70
|
+
}
|
|
71
|
+
if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
|
|
72
|
+
t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
61
77
|
raw := config.RawValues{
|
|
62
78
|
"initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
|
|
@@ -13,11 +13,13 @@ import (
|
|
|
13
13
|
"os/exec"
|
|
14
14
|
"path/filepath"
|
|
15
15
|
"regexp"
|
|
16
|
+
"strconv"
|
|
16
17
|
"strings"
|
|
17
18
|
|
|
18
19
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
19
20
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
20
21
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
22
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
const (
|
|
@@ -214,6 +216,10 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
214
216
|
} {
|
|
215
217
|
env[key] = value
|
|
216
218
|
}
|
|
219
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
220
|
+
// HITL policy is launch metadata, not part of the report wire object.
|
|
221
|
+
env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
|
|
222
|
+
}
|
|
217
223
|
args := []string{"--name", spec.Title}
|
|
218
224
|
if spec.Agent != "default" {
|
|
219
225
|
args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
|
|
@@ -2,8 +2,24 @@ package pi
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
5
7
|
)
|
|
6
8
|
|
|
9
|
+
func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
|
|
10
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
11
|
+
spec := launchSpec(t)
|
|
12
|
+
spec.NodeType = workflow.NodeHITL
|
|
13
|
+
spec.AutoReject = true
|
|
14
|
+
cmd, err := (&Harness{}).BuildCommand(spec)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
17
|
+
}
|
|
18
|
+
if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
|
|
19
|
+
t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
// TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
|
|
8
24
|
// workspace environment reaches the runner command so agent task commands
|
|
9
25
|
// address the same workspace as relay-flow, and that relay-flow's own
|