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
|
@@ -4,6 +4,8 @@ package jira
|
|
|
4
4
|
|
|
5
5
|
import (
|
|
6
6
|
"context"
|
|
7
|
+
"crypto/sha256"
|
|
8
|
+
"encoding/hex"
|
|
7
9
|
"errors"
|
|
8
10
|
"fmt"
|
|
9
11
|
"log/slog"
|
|
@@ -113,6 +115,72 @@ var (
|
|
|
113
115
|
// claimLabel is the permanent workflow claim label.
|
|
114
116
|
func claimLabel(workflow string) string { return "wf:" + workflow }
|
|
115
117
|
|
|
118
|
+
// claimOwnerLabel is a durable provider-owned provenance label. The owner
|
|
119
|
+
// identity is hashed so the marker does not expose an email address in Jira
|
|
120
|
+
// labels; the adapter compares it with the same normalized assignee identities
|
|
121
|
+
// used by filters. A marker remains after reassignment, so another server
|
|
122
|
+
// cannot silently take over an existing wf: claim. Only an explicit
|
|
123
|
+
// provider-side handoff may replace it; automatic routing never does.
|
|
124
|
+
const claimOwnerLabelPrefix = "wf-owner:"
|
|
125
|
+
|
|
126
|
+
func claimOwnerToken(owner string) string {
|
|
127
|
+
owner = strings.ToLower(strings.TrimSpace(owner))
|
|
128
|
+
if owner == "" {
|
|
129
|
+
return ""
|
|
130
|
+
}
|
|
131
|
+
sum := sha256.Sum256([]byte(owner))
|
|
132
|
+
return hex.EncodeToString(sum[:])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func claimOwnerLabel(workflow, owner string) string {
|
|
136
|
+
token := claimOwnerToken(owner)
|
|
137
|
+
if token == "" {
|
|
138
|
+
return ""
|
|
139
|
+
}
|
|
140
|
+
return claimOwnerLabelPrefix + workflow + ":" + token
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func claimOwnerMarkers(labels []string, workflow string) []string {
|
|
144
|
+
prefix := claimOwnerLabelPrefix + workflow + ":"
|
|
145
|
+
markers := make([]string, 0, 1)
|
|
146
|
+
for _, label := range labels {
|
|
147
|
+
if strings.HasPrefix(label, prefix) {
|
|
148
|
+
markers = append(markers, label)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return markers
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func hasForeignClaimOwnerMarker(labels []string, workflow string) bool {
|
|
155
|
+
currentPrefix := claimOwnerLabelPrefix + workflow + ":"
|
|
156
|
+
for _, label := range labels {
|
|
157
|
+
if strings.HasPrefix(label, claimOwnerLabelPrefix) && !strings.HasPrefix(label, currentPrefix) {
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func claimOwnerMatches(labels []string, workflow string, assignees []string) bool {
|
|
165
|
+
markers := claimOwnerMarkers(labels, workflow)
|
|
166
|
+
if len(markers) != 1 {
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
for _, assignee := range assignees {
|
|
170
|
+
if markers[0] == claimOwnerLabel(workflow, assignee) {
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func claimedWorkflow(ticket task.Ticket) string {
|
|
178
|
+
if len(ticket.WorkflowClaims) != 1 || !strings.HasPrefix(ticket.WorkflowClaims[0], "wf:") {
|
|
179
|
+
return ""
|
|
180
|
+
}
|
|
181
|
+
return strings.TrimPrefix(ticket.WorkflowClaims[0], "wf:")
|
|
182
|
+
}
|
|
183
|
+
|
|
116
184
|
func jiraRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
117
185
|
fields := []task.RegistrationField{
|
|
118
186
|
{Key: "project", Title: "Jira project"},
|
|
@@ -566,14 +634,165 @@ func (s *system) resolveAssigneeFilters(values []string) ([]string, error) {
|
|
|
566
634
|
return resolved, nil
|
|
567
635
|
}
|
|
568
636
|
|
|
637
|
+
// CompileOwnershipFilter compiles only the effective assignee predicate used
|
|
638
|
+
// to isolate claimed tickets. Lifecycle fields deliberately do not enter this
|
|
639
|
+
// matcher because claimed routing must survive status and label changes.
|
|
640
|
+
func (s *system) CompileOwnershipFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
|
|
641
|
+
merged := config.Merge(s.base, workflowTaskConfig)
|
|
642
|
+
cfg, err := decodeConfig(merged)
|
|
643
|
+
if err != nil {
|
|
644
|
+
return nil, err
|
|
645
|
+
}
|
|
646
|
+
assignees, err := s.resolveAssigneeFilters(cfg.Filters.Assignees)
|
|
647
|
+
if err != nil {
|
|
648
|
+
return nil, err
|
|
649
|
+
}
|
|
650
|
+
return func(ticket task.Ticket) bool {
|
|
651
|
+
if len(assignees) == 0 {
|
|
652
|
+
return true
|
|
653
|
+
}
|
|
654
|
+
workflow := claimedWorkflow(ticket)
|
|
655
|
+
owner := strField(ticket.Fields, "assignee")
|
|
656
|
+
return workflow != "" && containsFold(assignees, owner) &&
|
|
657
|
+
claimOwnerMatches(strSliceField(ticket.Fields, "labels"), workflow, []string{owner})
|
|
658
|
+
}, nil
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
func (s *system) ownershipAssignees(workflowTaskConfig config.RawValues) ([]string, error) {
|
|
662
|
+
merged := config.Merge(s.base, workflowTaskConfig)
|
|
663
|
+
cfg, err := decodeConfig(merged)
|
|
664
|
+
if err != nil {
|
|
665
|
+
return nil, err
|
|
666
|
+
}
|
|
667
|
+
return s.resolveAssigneeFilters(cfg.Filters.Assignees)
|
|
668
|
+
}
|
|
669
|
+
|
|
569
670
|
// --- Claim ---
|
|
570
671
|
|
|
571
672
|
// Claim adds wf:<workflow> using the claims already inspected by routing.
|
|
572
|
-
// Jira's label-add operation is idempotent.
|
|
673
|
+
// Jira's label-add operation is idempotent. Automatic routing uses
|
|
674
|
+
// ClaimIfOwned so the final ownership read happens immediately before this
|
|
675
|
+
// write; this narrow method remains the task.System primitive.
|
|
573
676
|
func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
|
|
574
677
|
return s.cli.EnsureLabel(ctx, ticket.Key, claimLabel(workflow))
|
|
575
678
|
}
|
|
576
679
|
|
|
680
|
+
// ValidateOwnership re-reads the current Jira assignee and claim-owner
|
|
681
|
+
// marker. Both must satisfy the effective ownership filter; the provenance
|
|
682
|
+
// marker additionally prevents a different owner from taking over a claim.
|
|
683
|
+
func (s *system) ValidateOwnership(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
|
|
684
|
+
assignees, err := s.ownershipAssignees(workflowTaskConfig)
|
|
685
|
+
if err != nil {
|
|
686
|
+
return err
|
|
687
|
+
}
|
|
688
|
+
if len(assignees) == 0 {
|
|
689
|
+
return nil
|
|
690
|
+
}
|
|
691
|
+
key := ticket.Key
|
|
692
|
+
if strings.TrimSpace(key) == "" {
|
|
693
|
+
key = ticket.ID
|
|
694
|
+
}
|
|
695
|
+
raw, err := s.cli.View(ctx, key)
|
|
696
|
+
if err != nil {
|
|
697
|
+
return fmt.Errorf("jira: read ticket %s ownership: %w", key, err)
|
|
698
|
+
}
|
|
699
|
+
current, err := normalizeIssue(raw)
|
|
700
|
+
if err != nil {
|
|
701
|
+
return fmt.Errorf("jira: parse ticket %s ownership: %w", key, err)
|
|
702
|
+
}
|
|
703
|
+
owner := strField(current.Fields, "assignee")
|
|
704
|
+
if !containsFold(assignees, owner) ||
|
|
705
|
+
!claimOwnerMatches(strSliceField(current.Fields, "labels"), workflow, []string{owner}) {
|
|
706
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
707
|
+
}
|
|
708
|
+
return nil
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ClaimIfOwned closes the poll-to-claim race with an adapter-owned final
|
|
712
|
+
// read/check immediately before the idempotent Jira label update. The owner
|
|
713
|
+
// marker is written before the wf: label so a crash cannot leave a new claim
|
|
714
|
+
// with no provenance; a later poll can finish the idempotent label update.
|
|
715
|
+
func (s *system) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
|
|
716
|
+
assignees, err := s.ownershipAssignees(workflowTaskConfig)
|
|
717
|
+
if err != nil {
|
|
718
|
+
return err
|
|
719
|
+
}
|
|
720
|
+
if len(assignees) == 0 {
|
|
721
|
+
return s.Claim(ctx, ticket, workflow)
|
|
722
|
+
}
|
|
723
|
+
key := ticket.Key
|
|
724
|
+
if strings.TrimSpace(key) == "" {
|
|
725
|
+
key = ticket.ID
|
|
726
|
+
}
|
|
727
|
+
raw, err := s.cli.View(ctx, key)
|
|
728
|
+
if err != nil {
|
|
729
|
+
return fmt.Errorf("jira: read ticket %s before claim: %w", key, err)
|
|
730
|
+
}
|
|
731
|
+
current, err := normalizeIssue(raw)
|
|
732
|
+
if err != nil {
|
|
733
|
+
return fmt.Errorf("jira: parse ticket %s before claim: %w", key, err)
|
|
734
|
+
}
|
|
735
|
+
owner := strField(current.Fields, "assignee")
|
|
736
|
+
if !containsFold(assignees, owner) {
|
|
737
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
738
|
+
}
|
|
739
|
+
labels := strSliceField(current.Fields, "labels")
|
|
740
|
+
markers := claimOwnerMarkers(labels, workflow)
|
|
741
|
+
if hasForeignClaimOwnerMarker(labels, workflow) || len(markers) > 1 || (len(markers) == 1 && !claimOwnerMatches(labels, workflow, []string{owner})) {
|
|
742
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
743
|
+
}
|
|
744
|
+
if len(current.WorkflowClaims) > 0 {
|
|
745
|
+
if len(current.WorkflowClaims) != 1 || current.WorkflowClaims[0] != claimLabel(workflow) || len(markers) != 1 {
|
|
746
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
747
|
+
}
|
|
748
|
+
return s.cli.EnsureLabels(ctx, key, []string{markers[0], claimLabel(workflow)})
|
|
749
|
+
}
|
|
750
|
+
ownerLabel := claimOwnerLabel(workflow, owner)
|
|
751
|
+
if ownerLabel == "" {
|
|
752
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
753
|
+
}
|
|
754
|
+
return s.cli.EnsureLabels(ctx, key, []string{ownerLabel, claimLabel(workflow)})
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// BackfillClaimOwner is an explicit repair operation for a legacy claim that
|
|
758
|
+
// has no provenance marker. It never removes labels or comments and is not
|
|
759
|
+
// called by automatic routing; an operator/provider handoff must invoke it.
|
|
760
|
+
func (s *system) BackfillClaimOwner(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
|
|
761
|
+
assignees, err := s.ownershipAssignees(workflowTaskConfig)
|
|
762
|
+
if err != nil {
|
|
763
|
+
return err
|
|
764
|
+
}
|
|
765
|
+
if len(assignees) == 0 {
|
|
766
|
+
return nil
|
|
767
|
+
}
|
|
768
|
+
key := ticket.Key
|
|
769
|
+
if strings.TrimSpace(key) == "" {
|
|
770
|
+
key = ticket.ID
|
|
771
|
+
}
|
|
772
|
+
raw, err := s.cli.View(ctx, key)
|
|
773
|
+
if err != nil {
|
|
774
|
+
return fmt.Errorf("jira: read ticket %s for ownership backfill: %w", key, err)
|
|
775
|
+
}
|
|
776
|
+
current, err := normalizeIssue(raw)
|
|
777
|
+
if err != nil {
|
|
778
|
+
return fmt.Errorf("jira: parse ticket %s for ownership backfill: %w", key, err)
|
|
779
|
+
}
|
|
780
|
+
owner := strField(current.Fields, "assignee")
|
|
781
|
+
if !containsFold(assignees, owner) ||
|
|
782
|
+
len(current.WorkflowClaims) != 1 || current.WorkflowClaims[0] != claimLabel(workflow) {
|
|
783
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
784
|
+
}
|
|
785
|
+
labels := strSliceField(current.Fields, "labels")
|
|
786
|
+
markers := claimOwnerMarkers(labels, workflow)
|
|
787
|
+
if len(markers) > 1 || (len(markers) == 1 && !claimOwnerMatches(labels, workflow, []string{owner})) {
|
|
788
|
+
return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
|
|
789
|
+
}
|
|
790
|
+
if len(markers) == 1 {
|
|
791
|
+
return nil
|
|
792
|
+
}
|
|
793
|
+
return s.cli.EnsureLabels(ctx, key, []string{claimOwnerLabel(workflow, owner)})
|
|
794
|
+
}
|
|
795
|
+
|
|
577
796
|
// --- Config validation ---
|
|
578
797
|
|
|
579
798
|
// ValidateConfig strictly validates the workflow and every node task config
|
|
@@ -917,7 +1136,12 @@ func strSliceField(fields map[string]any, key string) []string {
|
|
|
917
1136
|
}
|
|
918
1137
|
|
|
919
1138
|
var (
|
|
920
|
-
_ task.System
|
|
921
|
-
_ task.
|
|
922
|
-
_ task.
|
|
1139
|
+
_ task.System = (*system)(nil)
|
|
1140
|
+
_ task.OwnershipCapabilities = (*system)(nil)
|
|
1141
|
+
_ task.OwnershipFilterCompiler = (*system)(nil)
|
|
1142
|
+
_ task.OwnershipValidator = (*system)(nil)
|
|
1143
|
+
_ task.ConditionalClaimer = (*system)(nil)
|
|
1144
|
+
_ task.ClaimOwnerBackfiller = (*system)(nil)
|
|
1145
|
+
_ task.LifecycleDefaults = (*system)(nil)
|
|
1146
|
+
_ task.RestartPreparer = (*system)(nil)
|
|
923
1147
|
)
|
|
@@ -66,25 +66,40 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
|
|
|
66
66
|
if hasOpenBlocker(issue) {
|
|
67
67
|
continue
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
"status": issue.Fields.Status.Name,
|
|
71
|
-
"issueType": issue.Fields.IssueType.Name,
|
|
72
|
-
"labels": append([]string{}, issue.Fields.Labels...),
|
|
73
|
-
}
|
|
74
|
-
if issue.Fields.Assignee != nil {
|
|
75
|
-
fields["assignee"] = issue.Fields.Assignee.EmailAddress
|
|
76
|
-
}
|
|
77
|
-
out = append(out, task.Ticket{
|
|
78
|
-
ID: issue.ID,
|
|
79
|
-
Key: issue.Key,
|
|
80
|
-
Title: issue.Fields.Summary,
|
|
81
|
-
WorkflowClaims: claimLabels(issue.Fields.Labels),
|
|
82
|
-
Fields: fields,
|
|
83
|
-
})
|
|
69
|
+
out = append(out, normalizeIssueValue(issue))
|
|
84
70
|
}
|
|
85
71
|
return out, nil
|
|
86
72
|
}
|
|
87
73
|
|
|
74
|
+
// normalizeIssue parses a single Jira issue returned by View. Search returns
|
|
75
|
+
// an array, while final claim ownership checks use the task-system's single
|
|
76
|
+
// issue response shape.
|
|
77
|
+
func normalizeIssue(raw []byte) (task.Ticket, error) {
|
|
78
|
+
var issue rawIssue
|
|
79
|
+
if err := json.Unmarshal(raw, &issue); err != nil {
|
|
80
|
+
return task.Ticket{}, err
|
|
81
|
+
}
|
|
82
|
+
return normalizeIssueValue(issue), nil
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func normalizeIssueValue(issue rawIssue) task.Ticket {
|
|
86
|
+
fields := map[string]any{
|
|
87
|
+
"status": issue.Fields.Status.Name,
|
|
88
|
+
"issueType": issue.Fields.IssueType.Name,
|
|
89
|
+
"labels": append([]string{}, issue.Fields.Labels...),
|
|
90
|
+
}
|
|
91
|
+
if issue.Fields.Assignee != nil {
|
|
92
|
+
fields["assignee"] = issue.Fields.Assignee.EmailAddress
|
|
93
|
+
}
|
|
94
|
+
return task.Ticket{
|
|
95
|
+
ID: issue.ID,
|
|
96
|
+
Key: issue.Key,
|
|
97
|
+
Title: issue.Fields.Summary,
|
|
98
|
+
WorkflowClaims: claimLabels(issue.Fields.Labels),
|
|
99
|
+
Fields: fields,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
func hasOpenBlocker(issue rawIssue) bool {
|
|
89
104
|
for _, link := range issue.Fields.IssueLinks {
|
|
90
105
|
if link.Type.Name == "Blocks" && link.InwardIssue != nil &&
|
|
@@ -52,6 +52,7 @@ type Client interface {
|
|
|
52
52
|
CreateSubtasks(context.Context, string, string, string, []SubtaskSpec) ([]CreatedSubtask, error)
|
|
53
53
|
UpdateMailbox(context.Context, string, string, string) error
|
|
54
54
|
EnsureLabel(context.Context, string, string) error
|
|
55
|
+
EnsureLabels(context.Context, string, []string) error
|
|
55
56
|
Transition(context.Context, string, string, string) error
|
|
56
57
|
ListComments(context.Context, string) ([]string, error)
|
|
57
58
|
AddComment(context.Context, string, string) error
|
|
@@ -265,7 +266,7 @@ func (c *HTTPClient) loadStatuses(ctx context.Context, project string) (map[stri
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
func (c *HTTPClient) View(ctx context.Context, key string) ([]byte, error) {
|
|
268
|
-
q := url.Values{"fields": {"summary,status,issuetype,labels,subtasks"}}
|
|
269
|
+
q := url.Values{"fields": {"summary,status,issuetype,labels,assignee,subtasks"}}
|
|
269
270
|
var raw json.RawMessage
|
|
270
271
|
if err := c.request(ctx, http.MethodGet, "/rest/api/3/issue/"+url.PathEscape(key), q, nil, &raw, true); err != nil {
|
|
271
272
|
return nil, err
|
|
@@ -347,7 +348,21 @@ func (c *HTTPClient) UpdateMailbox(ctx context.Context, key, description, label
|
|
|
347
348
|
}
|
|
348
349
|
|
|
349
350
|
func (c *HTTPClient) EnsureLabel(ctx context.Context, key, label string) error {
|
|
350
|
-
|
|
351
|
+
return c.EnsureLabels(ctx, key, []string{label})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
func (c *HTTPClient) EnsureLabels(ctx context.Context, key string, labels []string) error {
|
|
355
|
+
updates := make([]any, 0, len(labels))
|
|
356
|
+
for _, label := range labels {
|
|
357
|
+
if strings.TrimSpace(label) == "" {
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
updates = append(updates, map[string]any{"add": label})
|
|
361
|
+
}
|
|
362
|
+
if len(updates) == 0 {
|
|
363
|
+
return errors.New("at least one Jira label is required")
|
|
364
|
+
}
|
|
365
|
+
body := map[string]any{"update": map[string]any{"labels": updates}}
|
|
351
366
|
return c.request(ctx, http.MethodPut, "/rest/api/3/issue/"+url.PathEscape(key), nil, body, nil, false)
|
|
352
367
|
}
|
|
353
368
|
|
|
@@ -29,7 +29,9 @@ type fakeJira struct {
|
|
|
29
29
|
transitionErr error
|
|
30
30
|
events []string
|
|
31
31
|
// searchJSON is the raw Jira search response Poll serves.
|
|
32
|
-
searchJSON
|
|
32
|
+
searchJSON []byte
|
|
33
|
+
// viewJSON is the single-issue response used by final ownership checks.
|
|
34
|
+
viewJSON []byte
|
|
33
35
|
comments []string
|
|
34
36
|
addedComments []string
|
|
35
37
|
labelCalls []string
|
package/internal/task/task.go
CHANGED
|
@@ -4,6 +4,8 @@ package task
|
|
|
4
4
|
|
|
5
5
|
import (
|
|
6
6
|
"context"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
7
9
|
|
|
8
10
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
11
|
)
|
|
@@ -99,6 +101,70 @@ type System interface {
|
|
|
99
101
|
ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
// ErrOwnershipMismatch identifies a task-system ownership predicate that did
|
|
105
|
+
// not accept the current ticket. Adapters return OwnershipMismatchError from
|
|
106
|
+
// their last-mile claim check and from claimed-run revalidation; core can
|
|
107
|
+
// classify the race without learning provider-specific owner fields.
|
|
108
|
+
var ErrOwnershipMismatch = errors.New("ticket owner does not match workflow")
|
|
109
|
+
|
|
110
|
+
// OwnershipMismatchError is the provider-neutral shape of an ownership
|
|
111
|
+
// rejection. It intentionally omits the current owner so diagnostics do not
|
|
112
|
+
// expose unnecessary personal data.
|
|
113
|
+
type OwnershipMismatchError struct {
|
|
114
|
+
Ticket string
|
|
115
|
+
Workflow string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (e *OwnershipMismatchError) Error() string {
|
|
119
|
+
return fmt.Sprintf("ticket %s is not owned by workflow %s", e.Ticket, e.Workflow)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func (e *OwnershipMismatchError) Unwrap() error { return ErrOwnershipMismatch }
|
|
123
|
+
|
|
124
|
+
// OwnershipFilterCompiler is one part of the required automatic-routing
|
|
125
|
+
// ownership capability set. The returned matcher contains only the
|
|
126
|
+
// ownership/assignee predicate from the effective workflow config; it must not
|
|
127
|
+
// repeat lifecycle filters such as status, issue type, or ordinary labels on a
|
|
128
|
+
// claimed-ticket route.
|
|
129
|
+
type OwnershipFilterCompiler interface {
|
|
130
|
+
CompileOwnershipFilter(workflowTaskConfig config.RawValues) (func(Ticket) bool, error)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// OwnershipValidator is one part of the required automatic-routing
|
|
134
|
+
// ownership capability set. It is used at the last-mile boundary and before
|
|
135
|
+
// claimed-run recovery, re-reading the provider-owned ticket and evaluating
|
|
136
|
+
// the effective ownership predicate.
|
|
137
|
+
type OwnershipValidator interface {
|
|
138
|
+
ValidateOwnership(ctx context.Context, ticket TicketRef, workflow string, workflowTaskConfig config.RawValues) error
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ConditionalClaimer is one part of the required automatic-routing
|
|
142
|
+
// ownership capability set. It replaces Claim for an unassigned poll result;
|
|
143
|
+
// the adapter performs its final ownership read immediately before the
|
|
144
|
+
// idempotent workflow-label write, closing the poll-to-claim reassignment race.
|
|
145
|
+
type ConditionalClaimer interface {
|
|
146
|
+
ClaimIfOwned(ctx context.Context, ticket TicketRef, workflow string, workflowTaskConfig config.RawValues) error
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// OwnershipCapabilities is the complete capability set required by an
|
|
150
|
+
// adapter that participates in automatic workflow routing. Keeping this as a
|
|
151
|
+
// separate boundary leaves the core System contract focused on task
|
|
152
|
+
// primitives while allowing registration to fail closed when an adapter would
|
|
153
|
+
// otherwise silently fall back to unrestricted Claim behavior.
|
|
154
|
+
type OwnershipCapabilities interface {
|
|
155
|
+
OwnershipFilterCompiler
|
|
156
|
+
OwnershipValidator
|
|
157
|
+
ConditionalClaimer
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ClaimOwnerBackfiller is an explicit provider-side repair capability for a
|
|
161
|
+
// legacy wf: claim that predates ownership provenance. Normal routing never
|
|
162
|
+
// invokes it; an operator/provider workflow may add the durable marker after
|
|
163
|
+
// verifying the live assignee, without removing the existing claim or history.
|
|
164
|
+
type ClaimOwnerBackfiller interface {
|
|
165
|
+
BackfillClaimOwner(ctx context.Context, ticket TicketRef, workflow string, workflowTaskConfig config.RawValues) error
|
|
166
|
+
}
|
|
167
|
+
|
|
102
168
|
// AgentEnvironment is an optional adapter capability. It returns the
|
|
103
169
|
// task-system workspace environment an agent process must receive so agent
|
|
104
170
|
// task commands address the same workspace as relay-flow. Values are
|
|
@@ -43,6 +43,9 @@ func (w *Workflow) ValidateReport(node string, report Report) error {
|
|
|
43
43
|
if report.Status != OutcomeSuccess && report.Status != OutcomeFailure {
|
|
44
44
|
return fmt.Errorf("report status %q must be %q or %q", report.Status, OutcomeSuccess, OutcomeFailure)
|
|
45
45
|
}
|
|
46
|
+
if report.Status == OutcomeFailure && report.NextStep == EndNode {
|
|
47
|
+
return fmt.Errorf("failure reports cannot select %q", EndNode)
|
|
48
|
+
}
|
|
46
49
|
for _, field := range []struct{ name, value string }{
|
|
47
50
|
{"summary.completed", report.Summary.Completed},
|
|
48
51
|
{"summary.commits", report.Summary.Commits},
|
|
@@ -181,6 +181,22 @@ func TestValidateReportStatusValues(t *testing.T) {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
func TestValidateReportRejectsFailureToEndEvenIfRouteTableContainsIt(t *testing.T) {
|
|
185
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
186
|
+
node := wf.Nodes["coding"]
|
|
187
|
+
node.OnFailure = append(node.OnFailure, workflow.Route{Target: workflow.EndNode})
|
|
188
|
+
wf.Nodes["coding"] = node
|
|
189
|
+
r := workflow.Report{
|
|
190
|
+
Status: workflow.OutcomeFailure,
|
|
191
|
+
NextStep: workflow.EndNode,
|
|
192
|
+
Summary: fullSummary(),
|
|
193
|
+
Feedback: noneFeedback(),
|
|
194
|
+
}
|
|
195
|
+
if err := wf.ValidateReport("coding", r); err == nil {
|
|
196
|
+
t.Fatal("failure report selecting end accepted")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
184
200
|
func TestValidateReportNextStepMustMatchStatusRoute(t *testing.T) {
|
|
185
201
|
wf := parse(t, "basicFlow", minimalValid)
|
|
186
202
|
|
|
@@ -65,9 +65,15 @@ type Node struct {
|
|
|
65
65
|
Agent string `yaml:"agent,omitempty" json:"agent,omitempty"`
|
|
66
66
|
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
|
67
67
|
NudgePrompt string `yaml:"nudgePrompt,omitempty" json:"nudgePrompt,omitempty"`
|
|
68
|
+
AutoReject bool `yaml:"autoReject,omitempty" json:"autoReject,omitempty"`
|
|
68
69
|
TaskConfig config.RawValues `yaml:"taskConfig,omitempty" json:"taskConfig,omitempty"`
|
|
69
70
|
OnSuccess []Route `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty"`
|
|
70
71
|
OnFailure []Route `yaml:"onFailure,omitempty" json:"onFailure,omitempty"`
|
|
72
|
+
|
|
73
|
+
// autoRejectSet lets validation distinguish an explicitly configured false
|
|
74
|
+
// from an omitted field. The marker is parser metadata and is not part of
|
|
75
|
+
// the workflow snapshot or JSON/YAML representation.
|
|
76
|
+
autoRejectSet bool
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
type Route struct {
|
|
@@ -119,6 +125,10 @@ func Parse(name string, yamlBytes []byte) (*Workflow, error) {
|
|
|
119
125
|
rawNodes, _ := raw["nodes"].(map[string]any)
|
|
120
126
|
for nodeName, n := range wf.Nodes {
|
|
121
127
|
if rn, ok := rawNodes[nodeName].(map[string]any); ok {
|
|
128
|
+
if _, present := rn["autoReject"]; present {
|
|
129
|
+
n.autoRejectSet = true
|
|
130
|
+
wf.Nodes[nodeName] = n
|
|
131
|
+
}
|
|
122
132
|
if v, present := rn["taskConfig"]; present && v == nil {
|
|
123
133
|
return nil, fmt.Errorf("parse workflow %q node %q: taskConfig: explicit null is not allowed", name, nodeName)
|
|
124
134
|
}
|
|
@@ -216,6 +226,9 @@ func (w *Workflow) Validate() error {
|
|
|
216
226
|
}
|
|
217
227
|
|
|
218
228
|
func validateStart(n Node) error {
|
|
229
|
+
if n.AutoReject || n.autoRejectSet {
|
|
230
|
+
return fmt.Errorf("autoReject is valid only on hitl nodes")
|
|
231
|
+
}
|
|
219
232
|
if n.Type != "" || n.Agent != "" || n.Description != "" || n.NudgePrompt != "" {
|
|
220
233
|
return fmt.Errorf("start must not declare type, agent, description, or nudgePrompt")
|
|
221
234
|
}
|
|
@@ -229,6 +242,9 @@ func validateStart(n Node) error {
|
|
|
229
242
|
}
|
|
230
243
|
|
|
231
244
|
func validateEnd(n Node) error {
|
|
245
|
+
if n.AutoReject || n.autoRejectSet {
|
|
246
|
+
return fmt.Errorf("autoReject is valid only on hitl nodes")
|
|
247
|
+
}
|
|
232
248
|
if n.Type != "" || n.Agent != "" || n.Description != "" || n.NudgePrompt != "" {
|
|
233
249
|
return fmt.Errorf("end must not declare type, agent, description, or nudgePrompt")
|
|
234
250
|
}
|
|
@@ -242,6 +258,9 @@ func validateWorkNode(wfName, name string, n Node) error {
|
|
|
242
258
|
if n.Type != NodeAgent && n.Type != NodeHITL {
|
|
243
259
|
return fmt.Errorf("workflow %q node %q: type must be %q or %q", wfName, name, NodeAgent, NodeHITL)
|
|
244
260
|
}
|
|
261
|
+
if n.Type != NodeHITL && (n.AutoReject || n.autoRejectSet) {
|
|
262
|
+
return fmt.Errorf("workflow %q node %q: autoReject is valid only on hitl nodes", wfName, name)
|
|
263
|
+
}
|
|
245
264
|
if n.Agent == "" {
|
|
246
265
|
return fmt.Errorf("workflow %q node %q: agent is required", wfName, name)
|
|
247
266
|
}
|
|
@@ -254,6 +273,11 @@ func validateWorkNode(wfName, name string, n Node) error {
|
|
|
254
273
|
if len(n.OnFailure) == 0 {
|
|
255
274
|
return fmt.Errorf("workflow %q node %q: at least one failure route is required", wfName, name)
|
|
256
275
|
}
|
|
276
|
+
for _, route := range n.OnFailure {
|
|
277
|
+
if route.Target == EndNode {
|
|
278
|
+
return fmt.Errorf("workflow %q node %q: failure routes cannot target %q", wfName, name, EndNode)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
257
281
|
for _, m := range nudgeVarPattern.FindAllStringSubmatch(n.NudgePrompt, -1) {
|
|
258
282
|
if !knownNudgeVars[m[1]] {
|
|
259
283
|
return fmt.Errorf("workflow %q node %q: unknown nudge template variable {{%s}}", wfName, name, m[1])
|
|
@@ -221,6 +221,63 @@ func TestValidateEndNode(t *testing.T) {
|
|
|
221
221
|
})
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
func TestAutoRejectPolicy(t *testing.T) {
|
|
225
|
+
t.Run("defaults false on HITL", func(t *testing.T) {
|
|
226
|
+
yaml := strings.Replace(minimalValid, " type: agent", " type: hitl", 1)
|
|
227
|
+
wf := parse(t, "basicFlow", yaml)
|
|
228
|
+
if err := wf.Validate(); err != nil {
|
|
229
|
+
t.Fatalf("HITL workflow rejected: %v", err)
|
|
230
|
+
}
|
|
231
|
+
if wf.Nodes["coding"].AutoReject {
|
|
232
|
+
t.Fatal("omitted autoReject defaulted to true")
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
t.Run("accepts true on HITL", func(t *testing.T) {
|
|
237
|
+
yaml := strings.Replace(minimalValid, " type: agent", " type: hitl\n autoReject: true", 1)
|
|
238
|
+
wf := parse(t, "basicFlow", yaml)
|
|
239
|
+
if err := wf.Validate(); err != nil {
|
|
240
|
+
t.Fatalf("HITL autoReject workflow rejected: %v", err)
|
|
241
|
+
}
|
|
242
|
+
if !wf.Nodes["coding"].AutoReject {
|
|
243
|
+
t.Fatal("autoReject true was not parsed")
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
for _, tc := range []struct {
|
|
248
|
+
name string
|
|
249
|
+
yaml string
|
|
250
|
+
}{
|
|
251
|
+
{
|
|
252
|
+
name: "agent",
|
|
253
|
+
yaml: strings.Replace(minimalValid, " type: agent", " type: agent\n autoReject: false", 1),
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "start",
|
|
257
|
+
yaml: strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n autoReject: false\n onSuccess:", 1),
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: "end",
|
|
261
|
+
yaml: strings.Replace(minimalValid, " end: {}", " end:\n autoReject: false", 1),
|
|
262
|
+
},
|
|
263
|
+
} {
|
|
264
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
265
|
+
wf := parse(t, "basicFlow", tc.yaml)
|
|
266
|
+
if err := wf.Validate(); err == nil {
|
|
267
|
+
t.Fatal("autoReject accepted outside a HITL node")
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
t.Run("rejects failure route to end", func(t *testing.T) {
|
|
273
|
+
yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: end", 1)
|
|
274
|
+
wf := parse(t, "basicFlow", yaml)
|
|
275
|
+
if err := wf.Validate(); err == nil {
|
|
276
|
+
t.Fatal("failure route to end accepted")
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
224
281
|
func TestValidateWorkNodes(t *testing.T) {
|
|
225
282
|
replaceCoding := func(node string) string {
|
|
226
283
|
return strings.Replace(minimalValid, " coding:\n type: agent\n agent: build\n description: Do the coding work.\n onSuccess:\n - target: end\n onFailure:\n - target: coding", node, 1)
|
|
@@ -303,7 +360,7 @@ func TestValidateRoutes(t *testing.T) {
|
|
|
303
360
|
}
|
|
304
361
|
})
|
|
305
362
|
t.Run("several routes for one outcome", func(t *testing.T) {
|
|
306
|
-
yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: coding\n when: retry\n - target:
|
|
363
|
+
yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: coding\n when: retry\n - target: coding\n when: try again", 1)
|
|
307
364
|
wf := parse(t, "basicFlow", yaml)
|
|
308
365
|
if err := wf.Validate(); err != nil {
|
|
309
366
|
t.Fatalf("two failure routes rejected: %v", err)
|