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.
- package/README.md +30 -34
- package/cmd/relay-flow/commands_test.go +449 -23
- package/cmd/relay-flow/main.go +207 -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/examples/config-reference.yaml +23 -19
- package/examples/workflow-reference.yaml +3 -0
- package/internal/execution/goworkflows/activities.go +13 -22
- package/internal/execution/goworkflows/engine_test.go +17 -3
- package/internal/execution/goworkflows/fakes_test.go +20 -0
- package/internal/execution/goworkflows/interpreter.go +25 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/recovery_test.go +81 -1
- package/internal/execution/temporal/activities.go +13 -22
- package/internal/execution/temporal/compact_mailbox_test.go +56 -0
- package/internal/execution/temporal/interpreter.go +16 -3
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +23 -10
- package/internal/harness/opencode/opencode.go +22 -4
- package/internal/harness/opencode/opencode_test.go +41 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +2 -2
- package/internal/harness/pi/pi.go +21 -2
- package/internal/harness/pi/pi_test.go +7 -4
- package/internal/harness/pi/prompt_test.go +13 -5
- package/internal/harness/pi/task_env_test.go +18 -2
- 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 +244 -15
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +67 -0
- package/internal/workflow/report.go +10 -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
|
@@ -72,13 +72,16 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
|
|
|
72
72
|
t.Fatal(err)
|
|
73
73
|
}
|
|
74
74
|
want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
|
|
75
|
+
if taskSystem == "jira" {
|
|
76
|
+
want = "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
|
|
77
|
+
}
|
|
75
78
|
if prompt != want {
|
|
76
79
|
t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
|
|
77
80
|
}
|
|
78
|
-
if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
|
|
81
|
+
if taskSystem != "jira" && (strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask")) {
|
|
79
82
|
t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
|
|
80
83
|
}
|
|
81
|
-
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
84
|
+
if strings.Contains(prompt, "STATUS:") || (taskSystem != "jira" && strings.Contains(prompt, node.Description)) {
|
|
82
85
|
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
83
86
|
}
|
|
84
87
|
}
|
|
@@ -467,6 +470,7 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
467
470
|
log := newEventLog()
|
|
468
471
|
sys := newFakeTaskSystem(log)
|
|
469
472
|
fr := newFakeRunner(log)
|
|
473
|
+
fh := newFakeHarness(log)
|
|
470
474
|
wf := workflow.Workflow{
|
|
471
475
|
Name: "reviewFlow", Repos: []string{"payments"},
|
|
472
476
|
Nodes: map[string]workflow.Node{
|
|
@@ -485,7 +489,7 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
485
489
|
},
|
|
486
490
|
}
|
|
487
491
|
engine := newEngine(t, goworkflows.Dependencies{
|
|
488
|
-
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness:
|
|
492
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
489
493
|
})
|
|
490
494
|
rid, _ := startRun(engine, wf)
|
|
491
495
|
waitFor(t, 10*time.Second, func() bool {
|
|
@@ -510,6 +514,16 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
510
514
|
t.Fatalf("HITL status was not set before terminal start; events=%v", events)
|
|
511
515
|
}
|
|
512
516
|
|
|
517
|
+
var sawReviewFeedback bool
|
|
518
|
+
for _, call := range fh.promptCalls() {
|
|
519
|
+
if call.Data.Node == "review" && call.Data.PreviousFeedback == "review it" {
|
|
520
|
+
sawReviewFeedback = true
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if !sawReviewFeedback {
|
|
524
|
+
t.Fatalf("next node prompt did not receive selected feedback: %+v", fh.promptCalls())
|
|
525
|
+
}
|
|
526
|
+
|
|
513
527
|
// Exact cross-primitive order, observed through the fake-adapter and fake-
|
|
514
528
|
// runner call logs (the settled observation seam):
|
|
515
529
|
// summary(current) -> feedback(selected next) -> CompleteMailbox(current)
|
|
@@ -73,6 +73,7 @@ type fakeTaskSystem struct {
|
|
|
73
73
|
labels map[string][]string // mailbox key -> labels
|
|
74
74
|
specs []task.MailboxSpec
|
|
75
75
|
comments []recordedComment
|
|
76
|
+
hasCommentN int
|
|
76
77
|
resets []string
|
|
77
78
|
renderText func(task.TextKind, task.TextData) (string, error)
|
|
78
79
|
|
|
@@ -115,6 +116,18 @@ func (s *fakeTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool
|
|
|
115
116
|
return func(task.Ticket) bool { return true }, nil
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
func (s *fakeTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
120
|
+
return func(task.Ticket) bool { return true }, nil
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func (s *fakeTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
124
|
+
return nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func (s *fakeTaskSystem) ClaimIfOwned(ctx context.Context, ref task.TicketRef, workflowName string, _ config.RawValues) error {
|
|
128
|
+
return s.Claim(ctx, ref, workflowName)
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
func (s *fakeTaskSystem) Claim(_ context.Context, ref task.TicketRef, wf string) error {
|
|
119
132
|
s.log.add("claim:" + ref.Key + ":" + wf)
|
|
120
133
|
return nil
|
|
@@ -208,6 +221,7 @@ func (s *fakeTaskSystem) CompleteMailbox(_ context.Context, mb task.Mailbox) err
|
|
|
208
221
|
|
|
209
222
|
func (s *fakeTaskSystem) HasComment(_ context.Context, target task.Target, marker string) (bool, error) {
|
|
210
223
|
s.mu.Lock()
|
|
224
|
+
s.hasCommentN++
|
|
211
225
|
defer s.mu.Unlock()
|
|
212
226
|
for _, c := range s.comments {
|
|
213
227
|
if c.Marker == marker {
|
|
@@ -267,6 +281,12 @@ func (s *fakeTaskSystem) ResetForRecovery(_ context.Context, parent task.TicketR
|
|
|
267
281
|
return nil
|
|
268
282
|
}
|
|
269
283
|
|
|
284
|
+
func (s *fakeTaskSystem) hasCommentCount() int {
|
|
285
|
+
s.mu.Lock()
|
|
286
|
+
defer s.mu.Unlock()
|
|
287
|
+
return s.hasCommentN
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
func (s *fakeTaskSystem) commentBodies(key string) []recordedComment {
|
|
271
291
|
s.mu.Lock()
|
|
272
292
|
defer s.mu.Unlock()
|
|
@@ -205,6 +205,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
current := target
|
|
208
|
+
previousFeedback := ""
|
|
208
209
|
seenReportIDs := map[string]bool{}
|
|
209
210
|
lastStepByNode := map[string]int64{}
|
|
210
211
|
lastDepthByNode := map[string]int{}
|
|
@@ -292,20 +293,22 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
292
293
|
Ticket: start.Ticket.Key,
|
|
293
294
|
Node: current,
|
|
294
295
|
NodeType: node.Type,
|
|
296
|
+
AutoReject: node.AutoReject,
|
|
295
297
|
Agent: node.Agent,
|
|
296
298
|
Title: title,
|
|
297
299
|
NudgePrompt: node.NudgePrompt,
|
|
298
300
|
PromptData: harness.PromptData{
|
|
299
|
-
TaskSystem:
|
|
300
|
-
Ticket:
|
|
301
|
-
Workflow:
|
|
302
|
-
Repo:
|
|
303
|
-
Node:
|
|
304
|
-
NodeType:
|
|
305
|
-
Agent:
|
|
306
|
-
NodeDescription:
|
|
307
|
-
NextSteps:
|
|
308
|
-
Mailbox:
|
|
301
|
+
TaskSystem: a.TaskSystem,
|
|
302
|
+
Ticket: start.Ticket.Key,
|
|
303
|
+
Workflow: wf.Name,
|
|
304
|
+
Repo: start.Repo,
|
|
305
|
+
Node: current,
|
|
306
|
+
NodeType: node.Type,
|
|
307
|
+
Agent: node.Agent,
|
|
308
|
+
NodeDescription: node.Description,
|
|
309
|
+
NextSteps: nextStepsText(nextSteps),
|
|
310
|
+
Mailbox: mb.Key,
|
|
311
|
+
PreviousFeedback: previousFeedback,
|
|
309
312
|
},
|
|
310
313
|
NextSteps: nextSteps,
|
|
311
314
|
}
|
|
@@ -394,7 +397,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
394
397
|
stepMessage := report.Summary.Completed
|
|
395
398
|
if report.Status == workflow.OutcomeFailure {
|
|
396
399
|
stepStatus = run.StepFailed
|
|
397
|
-
|
|
400
|
+
if report.Summary.IssuesDiscovered != workflow.None {
|
|
401
|
+
stepMessage = report.Summary.IssuesDiscovered
|
|
402
|
+
}
|
|
398
403
|
}
|
|
399
404
|
stepFinished := goworkflow.Now(ctx).UTC()
|
|
400
405
|
if _, err := retryLoop(ctx, start.ID, a, work, current,
|
|
@@ -464,6 +469,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
464
469
|
return err
|
|
465
470
|
}
|
|
466
471
|
|
|
472
|
+
previousFeedback = report.Feedback.RequiredActions
|
|
467
473
|
current = next
|
|
468
474
|
}
|
|
469
475
|
|
|
@@ -760,11 +766,19 @@ func nextStepsText(routes []workflow.Route) string {
|
|
|
760
766
|
}
|
|
761
767
|
|
|
762
768
|
func renderSummaryReport(r workflow.Report) string {
|
|
769
|
+
if r.Summary.Commits == workflow.None && r.Summary.NotCompleted == workflow.None &&
|
|
770
|
+
r.Summary.IssuesDiscovered == workflow.None && r.Summary.Verification == workflow.None && r.Summary.Notes == workflow.None {
|
|
771
|
+
return r.Summary.Completed
|
|
772
|
+
}
|
|
763
773
|
return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
|
|
764
774
|
r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
|
|
765
775
|
}
|
|
766
776
|
|
|
767
777
|
func renderFeedbackReport(r workflow.Report) string {
|
|
778
|
+
if r.Summary.Commits == workflow.None && r.Feedback.ReasonForNextStep == workflow.None &&
|
|
779
|
+
r.Feedback.RelevantContext == workflow.None && r.Feedback.ExpectedResult == workflow.None {
|
|
780
|
+
return r.Feedback.RequiredActions
|
|
781
|
+
}
|
|
768
782
|
return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
|
|
769
783
|
r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
|
|
770
784
|
}
|
|
@@ -41,6 +41,33 @@ func threeNodeWorkflow() workflow.Workflow {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
|
|
45
|
+
wf := threeNodeWorkflow()
|
|
46
|
+
sys := newFakeTaskSystem(newEventLog())
|
|
47
|
+
sys.renderText = func(_ task.TextKind, data task.TextData) (string, error) {
|
|
48
|
+
return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
|
|
49
|
+
"\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
|
|
50
|
+
"\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
|
|
51
|
+
}
|
|
52
|
+
specs, err := goworkflows.RenderMailboxSpecs(sys, run.Work{
|
|
53
|
+
Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
|
|
54
|
+
}, &wf)
|
|
55
|
+
if err != nil {
|
|
56
|
+
t.Fatal(err)
|
|
57
|
+
}
|
|
58
|
+
for _, spec := range specs {
|
|
59
|
+
for _, required := range []string{"PAY-101 / " + spec.Node, wf.Nodes[spec.Node].Description,
|
|
60
|
+
"Success routes:", "Failure routes:", " — when: ", workflow.ReportFormat} {
|
|
61
|
+
if !strings.Contains(spec.Description, required) {
|
|
62
|
+
t.Fatalf("mailbox %s description lacks %q: %q", spec.Node, required, spec.Description)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if strings.Contains(spec.Description, "Required report format:") {
|
|
66
|
+
t.Fatalf("mailbox %s appended generic instructions: %q", spec.Node, spec.Description)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
44
71
|
func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
|
|
45
72
|
log := newEventLog()
|
|
46
73
|
sys := newFakeTaskSystem(log)
|
|
@@ -107,7 +134,7 @@ func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
|
|
|
107
134
|
t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
|
|
108
135
|
}
|
|
109
136
|
}
|
|
110
|
-
for _, required := range []string{"Required report format:", "STATUS:", "
|
|
137
|
+
for _, required := range []string{"Required report format:", "STATUS:", "NEXT STEP:", "SUMMARY:", "FEEDBACK:"} {
|
|
111
138
|
if !strings.Contains(d, required) {
|
|
112
139
|
t.Fatalf("%s description lacks %q: %q", node, required, d)
|
|
113
140
|
}
|
|
@@ -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)
|
|
@@ -85,7 +85,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
|
|
|
85
85
|
if err != nil {
|
|
86
86
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
89
|
+
specs[i].Description = custom
|
|
90
|
+
} else {
|
|
91
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
92
|
+
}
|
|
89
93
|
}
|
|
90
94
|
return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
|
|
91
95
|
}
|
|
@@ -486,26 +490,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
|
|
|
486
490
|
var b strings.Builder
|
|
487
491
|
b.WriteString(`Required report format:
|
|
488
492
|
|
|
489
|
-
|
|
490
|
-
NEXT STEP: <one valid node name>
|
|
491
|
-
|
|
492
|
-
SUMMARY:
|
|
493
|
-
COMPLETED:
|
|
494
|
-
COMMITS:
|
|
495
|
-
NOT COMPLETED:
|
|
496
|
-
ISSUES DISCOVERED:
|
|
497
|
-
VERIFICATION:
|
|
498
|
-
NOTES:
|
|
499
|
-
|
|
500
|
-
FEEDBACK:
|
|
501
|
-
REASON FOR NEXT STEP:
|
|
502
|
-
REQUIRED ACTIONS:
|
|
503
|
-
RELEVANT CONTEXT:
|
|
504
|
-
EXPECTED RESULT:
|
|
493
|
+
` + workflow.ReportFormat + `
|
|
505
494
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
|
|
495
|
+
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
|
|
509
496
|
writeRoutes := func(label string, routes []workflow.Route) {
|
|
510
497
|
if len(routes) == 0 {
|
|
511
498
|
return
|
|
@@ -533,7 +520,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
|
|
|
533
520
|
Agent: n.Agent, NodeDescription: n.Description,
|
|
534
521
|
NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
|
|
535
522
|
SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
|
|
536
|
-
Mailbox: ticketKey + ":" + name,
|
|
523
|
+
Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
|
|
537
524
|
},
|
|
538
525
|
}
|
|
539
526
|
}
|
|
@@ -567,7 +554,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
|
|
|
567
554
|
if err != nil {
|
|
568
555
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
569
556
|
}
|
|
570
|
-
|
|
557
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
558
|
+
specs[i].Description = custom
|
|
559
|
+
} else {
|
|
560
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
561
|
+
}
|
|
571
562
|
}
|
|
572
563
|
return specs, nil
|
|
573
564
|
}
|