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.
Files changed (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. package/package.json +1 -1
@@ -24,6 +24,9 @@ func (f *fakeClient) ValidateAssignee(context.Context, string, string) error { r
24
24
  func (f *fakeClient) ValidateStatus(context.Context, string, string) error { return nil }
25
25
 
26
26
  func (f *fakeClient) View(context.Context, string) ([]byte, error) {
27
+ if f.fake.viewJSON != nil {
28
+ return f.fake.viewJSON, nil
29
+ }
27
30
  return []byte(`{"fields":{"labels":[],"subtasks":[]}}`), nil
28
31
  }
29
32
 
@@ -46,8 +49,14 @@ func (f *fakeClient) Transition(_ context.Context, key, status, assignee string)
46
49
  return nil
47
50
  }
48
51
 
49
- func (f *fakeClient) EnsureLabel(_ context.Context, key, label string) error {
50
- f.fake.labelCalls = append(f.fake.labelCalls, key+":"+label)
52
+ func (f *fakeClient) EnsureLabel(ctx context.Context, key, label string) error {
53
+ return f.EnsureLabels(ctx, key, []string{label})
54
+ }
55
+
56
+ func (f *fakeClient) EnsureLabels(_ context.Context, key string, labels []string) error {
57
+ for _, label := range labels {
58
+ f.fake.labelCalls = append(f.fake.labelCalls, key+":"+label)
59
+ }
51
60
  return nil
52
61
  }
53
62
 
@@ -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"
@@ -64,17 +66,16 @@ const (
64
66
  defaultWorkTaskStatus = "In Progress"
65
67
  defaultEndParentStatus = "Done"
66
68
  currentUserAssigneeFilter = "currentUser()"
67
- defaultMailboxDescription = `Parent ticket: {{ticket}}
68
- Workflow: {{workflow}}
69
- Node: {{node}}
70
- Node type: {{nodeType}}
71
- Agent: {{agent}}
72
- Mailbox: {{mailbox}}
73
-
74
- Node work:
69
+ defaultMailboxDescription = `{{ticket}} / {{node}} — {{nodeType}} — {{agent}}
75
70
  {{nodeDescription}}
76
71
 
77
- Read this mailbox's comments for feedback from previous nodes.`
72
+ Success routes:
73
+ {{successRoutes}}
74
+
75
+ Failure routes:
76
+ {{failureRoutes}}
77
+
78
+ {{report}}`
78
79
  defaultSummaryComment = `Summary for {{node}}
79
80
 
80
81
  {{summaryReport}}`
@@ -91,6 +92,7 @@ var knownTextVars = map[string]bool{
91
92
  "nodeDescription": true, "nextSteps": true, "successRoutes": true,
92
93
  "failureRoutes": true, "mailbox": true, "sourceNode": true,
93
94
  "targetNode": true, "summaryReport": true, "feedbackReport": true,
95
+ "report": true,
94
96
  }
95
97
 
96
98
  // DefaultConfig supplies Jira task-system text defaults for relay-flow init.
@@ -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"},
@@ -441,6 +509,11 @@ func validateTemplates(templates Templates) error {
441
509
  }
442
510
  }
443
511
  }
512
+ for _, key := range []string{"report", "ticket", "node", "nodeType", "agent", "nodeDescription", "successRoutes", "failureRoutes"} {
513
+ if !strings.Contains(templates.MailboxDescription, "{{"+key+"}}") {
514
+ return fmt.Errorf("mailboxDescription must contain {{%s}}", key)
515
+ }
516
+ }
444
517
  if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
445
518
  return fmt.Errorf("summaryComment must contain {{summaryReport}}")
446
519
  }
@@ -467,11 +540,11 @@ func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, err
467
540
  "runID": data.RunID, "ticket": data.Ticket,
468
541
  "workflow": data.Workflow, "repo": data.Repo, "node": data.Node,
469
542
  "nodeType": data.NodeType, "agent": data.Agent,
470
- "nodeDescription": data.NodeDescription, "nextSteps": data.NextSteps,
543
+ "nodeDescription": strings.ReplaceAll(data.NodeDescription, "{{ticket}}", data.Ticket), "nextSteps": data.NextSteps,
471
544
  "successRoutes": data.SuccessRoutes, "failureRoutes": data.FailureRoutes,
472
545
  "mailbox": data.Mailbox, "sourceNode": data.SourceNode,
473
546
  "targetNode": data.TargetNode, "summaryReport": data.SummaryReport,
474
- "feedbackReport": data.FeedbackReport,
547
+ "feedbackReport": data.FeedbackReport, "report": data.Report,
475
548
  }
476
549
  return textVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
477
550
  return values[textVarPattern.FindStringSubmatch(match)[1]]
@@ -566,14 +639,165 @@ func (s *system) resolveAssigneeFilters(values []string) ([]string, error) {
566
639
  return resolved, nil
567
640
  }
568
641
 
642
+ // CompileOwnershipFilter compiles only the effective assignee predicate used
643
+ // to isolate claimed tickets. Lifecycle fields deliberately do not enter this
644
+ // matcher because claimed routing must survive status and label changes.
645
+ func (s *system) CompileOwnershipFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
646
+ merged := config.Merge(s.base, workflowTaskConfig)
647
+ cfg, err := decodeConfig(merged)
648
+ if err != nil {
649
+ return nil, err
650
+ }
651
+ assignees, err := s.resolveAssigneeFilters(cfg.Filters.Assignees)
652
+ if err != nil {
653
+ return nil, err
654
+ }
655
+ return func(ticket task.Ticket) bool {
656
+ if len(assignees) == 0 {
657
+ return true
658
+ }
659
+ workflow := claimedWorkflow(ticket)
660
+ owner := strField(ticket.Fields, "assignee")
661
+ return workflow != "" && containsFold(assignees, owner) &&
662
+ claimOwnerMatches(strSliceField(ticket.Fields, "labels"), workflow, []string{owner})
663
+ }, nil
664
+ }
665
+
666
+ func (s *system) ownershipAssignees(workflowTaskConfig config.RawValues) ([]string, error) {
667
+ merged := config.Merge(s.base, workflowTaskConfig)
668
+ cfg, err := decodeConfig(merged)
669
+ if err != nil {
670
+ return nil, err
671
+ }
672
+ return s.resolveAssigneeFilters(cfg.Filters.Assignees)
673
+ }
674
+
569
675
  // --- Claim ---
570
676
 
571
677
  // Claim adds wf:<workflow> using the claims already inspected by routing.
572
- // Jira's label-add operation is idempotent.
678
+ // Jira's label-add operation is idempotent. Automatic routing uses
679
+ // ClaimIfOwned so the final ownership read happens immediately before this
680
+ // write; this narrow method remains the task.System primitive.
573
681
  func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
574
682
  return s.cli.EnsureLabel(ctx, ticket.Key, claimLabel(workflow))
575
683
  }
576
684
 
685
+ // ValidateOwnership re-reads the current Jira assignee and claim-owner
686
+ // marker. Both must satisfy the effective ownership filter; the provenance
687
+ // marker additionally prevents a different owner from taking over a claim.
688
+ func (s *system) ValidateOwnership(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
689
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
690
+ if err != nil {
691
+ return err
692
+ }
693
+ if len(assignees) == 0 {
694
+ return nil
695
+ }
696
+ key := ticket.Key
697
+ if strings.TrimSpace(key) == "" {
698
+ key = ticket.ID
699
+ }
700
+ raw, err := s.cli.View(ctx, key)
701
+ if err != nil {
702
+ return fmt.Errorf("jira: read ticket %s ownership: %w", key, err)
703
+ }
704
+ current, err := normalizeIssue(raw)
705
+ if err != nil {
706
+ return fmt.Errorf("jira: parse ticket %s ownership: %w", key, err)
707
+ }
708
+ owner := strField(current.Fields, "assignee")
709
+ if !containsFold(assignees, owner) ||
710
+ !claimOwnerMatches(strSliceField(current.Fields, "labels"), workflow, []string{owner}) {
711
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
712
+ }
713
+ return nil
714
+ }
715
+
716
+ // ClaimIfOwned closes the poll-to-claim race with an adapter-owned final
717
+ // read/check immediately before the idempotent Jira label update. The owner
718
+ // marker is written before the wf: label so a crash cannot leave a new claim
719
+ // with no provenance; a later poll can finish the idempotent label update.
720
+ func (s *system) ClaimIfOwned(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
721
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
722
+ if err != nil {
723
+ return err
724
+ }
725
+ if len(assignees) == 0 {
726
+ return s.Claim(ctx, ticket, workflow)
727
+ }
728
+ key := ticket.Key
729
+ if strings.TrimSpace(key) == "" {
730
+ key = ticket.ID
731
+ }
732
+ raw, err := s.cli.View(ctx, key)
733
+ if err != nil {
734
+ return fmt.Errorf("jira: read ticket %s before claim: %w", key, err)
735
+ }
736
+ current, err := normalizeIssue(raw)
737
+ if err != nil {
738
+ return fmt.Errorf("jira: parse ticket %s before claim: %w", key, err)
739
+ }
740
+ owner := strField(current.Fields, "assignee")
741
+ if !containsFold(assignees, owner) {
742
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
743
+ }
744
+ labels := strSliceField(current.Fields, "labels")
745
+ markers := claimOwnerMarkers(labels, workflow)
746
+ if hasForeignClaimOwnerMarker(labels, workflow) || len(markers) > 1 || (len(markers) == 1 && !claimOwnerMatches(labels, workflow, []string{owner})) {
747
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
748
+ }
749
+ if len(current.WorkflowClaims) > 0 {
750
+ if len(current.WorkflowClaims) != 1 || current.WorkflowClaims[0] != claimLabel(workflow) || len(markers) != 1 {
751
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
752
+ }
753
+ return s.cli.EnsureLabels(ctx, key, []string{markers[0], claimLabel(workflow)})
754
+ }
755
+ ownerLabel := claimOwnerLabel(workflow, owner)
756
+ if ownerLabel == "" {
757
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
758
+ }
759
+ return s.cli.EnsureLabels(ctx, key, []string{ownerLabel, claimLabel(workflow)})
760
+ }
761
+
762
+ // BackfillClaimOwner is an explicit repair operation for a legacy claim that
763
+ // has no provenance marker. It never removes labels or comments and is not
764
+ // called by automatic routing; an operator/provider handoff must invoke it.
765
+ func (s *system) BackfillClaimOwner(ctx context.Context, ticket task.TicketRef, workflow string, workflowTaskConfig config.RawValues) error {
766
+ assignees, err := s.ownershipAssignees(workflowTaskConfig)
767
+ if err != nil {
768
+ return err
769
+ }
770
+ if len(assignees) == 0 {
771
+ return nil
772
+ }
773
+ key := ticket.Key
774
+ if strings.TrimSpace(key) == "" {
775
+ key = ticket.ID
776
+ }
777
+ raw, err := s.cli.View(ctx, key)
778
+ if err != nil {
779
+ return fmt.Errorf("jira: read ticket %s for ownership backfill: %w", key, err)
780
+ }
781
+ current, err := normalizeIssue(raw)
782
+ if err != nil {
783
+ return fmt.Errorf("jira: parse ticket %s for ownership backfill: %w", key, err)
784
+ }
785
+ owner := strField(current.Fields, "assignee")
786
+ if !containsFold(assignees, owner) ||
787
+ len(current.WorkflowClaims) != 1 || current.WorkflowClaims[0] != claimLabel(workflow) {
788
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
789
+ }
790
+ labels := strSliceField(current.Fields, "labels")
791
+ markers := claimOwnerMarkers(labels, workflow)
792
+ if len(markers) > 1 || (len(markers) == 1 && !claimOwnerMatches(labels, workflow, []string{owner})) {
793
+ return &task.OwnershipMismatchError{Ticket: key, Workflow: workflow}
794
+ }
795
+ if len(markers) == 1 {
796
+ return nil
797
+ }
798
+ return s.cli.EnsureLabels(ctx, key, []string{claimOwnerLabel(workflow, owner)})
799
+ }
800
+
577
801
  // --- Config validation ---
578
802
 
579
803
  // ValidateConfig strictly validates the workflow and every node task config
@@ -917,7 +1141,12 @@ func strSliceField(fields map[string]any, key string) []string {
917
1141
  }
918
1142
 
919
1143
  var (
920
- _ task.System = (*system)(nil)
921
- _ task.LifecycleDefaults = (*system)(nil)
922
- _ task.RestartPreparer = (*system)(nil)
1144
+ _ task.System = (*system)(nil)
1145
+ _ task.OwnershipCapabilities = (*system)(nil)
1146
+ _ task.OwnershipFilterCompiler = (*system)(nil)
1147
+ _ task.OwnershipValidator = (*system)(nil)
1148
+ _ task.ConditionalClaimer = (*system)(nil)
1149
+ _ task.ClaimOwnerBackfiller = (*system)(nil)
1150
+ _ task.LifecycleDefaults = (*system)(nil)
1151
+ _ task.RestartPreparer = (*system)(nil)
923
1152
  )
@@ -66,25 +66,40 @@ func normalizeSearchResponse(raw []byte) ([]task.Ticket, error) {
66
66
  if hasOpenBlocker(issue) {
67
67
  continue
68
68
  }
69
- fields := map[string]any{
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
- body := map[string]any{"update": map[string]any{"labels": []any{map[string]any{"add": label}}}}
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
 
@@ -10,6 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  "github.com/rajpopat27/relay-flow/internal/run"
12
12
  "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
14
  )
14
15
 
15
16
  func TestTaskTextTemplatesExposeValues(t *testing.T) {
@@ -17,7 +18,7 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
17
18
  sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
18
19
  Name: "payments",
19
20
  RootConfig: config.RawValues{"templates": map[string]any{
20
- "mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}",
21
+ "mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}|{{report}}",
21
22
  "summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
22
23
  "feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
23
24
  }},
@@ -31,7 +32,7 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
31
32
  Node: "review", NodeType: "hitl", Agent: "reviewer", NodeDescription: "Review changes.",
32
33
  NextSteps: "implement; end", SuccessRoutes: "end", FailureRoutes: "implement",
33
34
  Mailbox: "PAY-3", SourceNode: "review", TargetNode: "implement",
34
- SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it",
35
+ SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it", Report: "STATUS: success | failure",
35
36
  }
36
37
  description, err := sys.RenderText(task.TextMailboxDescription, data)
37
38
  if err != nil {
@@ -52,12 +53,47 @@ func TestTaskTextTemplatesExposeValues(t *testing.T) {
52
53
  }
53
54
  }
54
55
 
56
+ func TestJiraDefaultMailboxDescriptionIsCompact(t *testing.T) {
57
+ sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
58
+ Name: "payments", RepoConfig: testRepoConfig(nil),
59
+ })
60
+ if err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ data := task.TextData{
64
+ Ticket: "PAY-101", Node: "coder", NodeType: "agent", Agent: "coder",
65
+ NodeDescription: "Implement the change.", SuccessRoutes: "reviewer — when: ready",
66
+ FailureRoutes: "coder — when: retry", Report: workflow.ReportFormat,
67
+ }
68
+ description, err := sys.RenderText(task.TextMailboxDescription, data)
69
+ if err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ want := "PAY-101 / coder — agent — coder\nImplement the change.\n\nSuccess routes:\nreviewer — when: ready\n\nFailure routes:\ncoder — when: retry\n\n" + workflow.ReportFormat
73
+ if description != want {
74
+ t.Fatalf("description = %q, want %q", description, want)
75
+ }
76
+ if strings.Contains(description, "comment history") {
77
+ t.Fatal("Jira description requested comment history")
78
+ }
79
+ data.NodeDescription = "Read the parent Jira ticket for this step:\nacli jira workitem view \"{{ticket}}\" --fields \"summary,description\" --json"
80
+ withParent, err := sys.RenderText(task.TextMailboxDescription, data)
81
+ if err != nil || !strings.Contains(withParent, `acli jira workitem view "PAY-101" --fields "summary,description" --json`) {
82
+ t.Fatalf("node-specific parent instruction = %q, %v", withParent, err)
83
+ }
84
+ }
85
+
55
86
  func TestCommentTemplatesRequireReportValues(t *testing.T) {
56
87
  for _, tc := range []struct {
57
88
  name string
58
89
  override map[string]any
59
90
  wantError string
60
91
  }{
92
+ {name: "description", override: map[string]any{"mailboxDescription": "missing"}, wantError: "mailboxDescription must contain {{report}}"},
93
+ {name: "report only", override: map[string]any{"mailboxDescription": "{{report}}"}, wantError: "mailboxDescription must contain {{ticket}}"},
94
+ {name: "no node work", override: map[string]any{"mailboxDescription": strings.Replace(defaultMailboxDescription, "{{nodeDescription}}", "No work provided", 1)}, wantError: "mailboxDescription must contain {{nodeDescription}}"},
95
+ {name: "no success routes", override: map[string]any{"mailboxDescription": strings.Replace(defaultMailboxDescription, "{{successRoutes}}", "None", 1)}, wantError: "mailboxDescription must contain {{successRoutes}}"},
96
+ {name: "no failure routes", override: map[string]any{"mailboxDescription": strings.Replace(defaultMailboxDescription, "{{failureRoutes}}", "None", 1)}, wantError: "mailboxDescription must contain {{failureRoutes}}"},
61
97
  {name: "summary", override: map[string]any{"summaryComment": "missing"}, wantError: "summaryComment must contain {{summaryReport}}"},
62
98
  {name: "feedback", override: map[string]any{"feedbackComment": "missing"}, wantError: "feedbackComment must contain {{feedbackReport}}"},
63
99
  {name: "unknown", override: map[string]any{"mailboxDescription": "{{mailboxInstructions}}"}, wantError: "unknown template variable {{mailboxInstructions}}"},
@@ -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 []byte
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
@@ -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
  )
@@ -68,6 +70,7 @@ type TextData struct {
68
70
  TargetNode string
69
71
  SummaryReport string
70
72
  FeedbackReport string
73
+ Report string
71
74
  }
72
75
 
73
76
  type Target struct {
@@ -99,6 +102,70 @@ type System interface {
99
102
  ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
100
103
  }
101
104
 
105
+ // ErrOwnershipMismatch identifies a task-system ownership predicate that did
106
+ // not accept the current ticket. Adapters return OwnershipMismatchError from
107
+ // their last-mile claim check and from claimed-run revalidation; core can
108
+ // classify the race without learning provider-specific owner fields.
109
+ var ErrOwnershipMismatch = errors.New("ticket owner does not match workflow")
110
+
111
+ // OwnershipMismatchError is the provider-neutral shape of an ownership
112
+ // rejection. It intentionally omits the current owner so diagnostics do not
113
+ // expose unnecessary personal data.
114
+ type OwnershipMismatchError struct {
115
+ Ticket string
116
+ Workflow string
117
+ }
118
+
119
+ func (e *OwnershipMismatchError) Error() string {
120
+ return fmt.Sprintf("ticket %s is not owned by workflow %s", e.Ticket, e.Workflow)
121
+ }
122
+
123
+ func (e *OwnershipMismatchError) Unwrap() error { return ErrOwnershipMismatch }
124
+
125
+ // OwnershipFilterCompiler is one part of the required automatic-routing
126
+ // ownership capability set. The returned matcher contains only the
127
+ // ownership/assignee predicate from the effective workflow config; it must not
128
+ // repeat lifecycle filters such as status, issue type, or ordinary labels on a
129
+ // claimed-ticket route.
130
+ type OwnershipFilterCompiler interface {
131
+ CompileOwnershipFilter(workflowTaskConfig config.RawValues) (func(Ticket) bool, error)
132
+ }
133
+
134
+ // OwnershipValidator is one part of the required automatic-routing
135
+ // ownership capability set. It is used at the last-mile boundary and before
136
+ // claimed-run recovery, re-reading the provider-owned ticket and evaluating
137
+ // the effective ownership predicate.
138
+ type OwnershipValidator interface {
139
+ ValidateOwnership(ctx context.Context, ticket TicketRef, workflow string, workflowTaskConfig config.RawValues) error
140
+ }
141
+
142
+ // ConditionalClaimer is one part of the required automatic-routing
143
+ // ownership capability set. It replaces Claim for an unassigned poll result;
144
+ // the adapter performs its final ownership read immediately before the
145
+ // idempotent workflow-label write, closing the poll-to-claim reassignment race.
146
+ type ConditionalClaimer interface {
147
+ ClaimIfOwned(ctx context.Context, ticket TicketRef, workflow string, workflowTaskConfig config.RawValues) error
148
+ }
149
+
150
+ // OwnershipCapabilities is the complete capability set required by an
151
+ // adapter that participates in automatic workflow routing. Keeping this as a
152
+ // separate boundary leaves the core System contract focused on task
153
+ // primitives while allowing registration to fail closed when an adapter would
154
+ // otherwise silently fall back to unrestricted Claim behavior.
155
+ type OwnershipCapabilities interface {
156
+ OwnershipFilterCompiler
157
+ OwnershipValidator
158
+ ConditionalClaimer
159
+ }
160
+
161
+ // ClaimOwnerBackfiller is an explicit provider-side repair capability for a
162
+ // legacy wf: claim that predates ownership provenance. Normal routing never
163
+ // invokes it; an operator/provider workflow may add the durable marker after
164
+ // verifying the live assignee, without removing the existing claim or history.
165
+ type ClaimOwnerBackfiller interface {
166
+ BackfillClaimOwner(ctx context.Context, ticket TicketRef, workflow string, workflowTaskConfig config.RawValues) error
167
+ }
168
+
102
169
  // AgentEnvironment is an optional adapter capability. It returns the
103
170
  // task-system workspace environment an agent process must receive so agent
104
171
  // task commands address the same workspace as relay-flow. Values are
@@ -9,6 +9,13 @@ import (
9
9
  // represents an intentionally empty section.
10
10
  const None = "None"
11
11
 
12
+ // ReportFormat is the canonical agent-facing contract. Runtime plugins parse
13
+ // these four fields into the existing structured Report wire shape.
14
+ const ReportFormat = `STATUS: success | failure
15
+ NEXT STEP: <one valid route>
16
+ SUMMARY: <concise result>
17
+ FEEDBACK: <concise handoff, or None when NEXT STEP is end>`
18
+
12
19
  type Summary struct {
13
20
  Completed string `json:"completed"`
14
21
  Commits string `json:"commits"`
@@ -43,6 +50,9 @@ func (w *Workflow) ValidateReport(node string, report Report) error {
43
50
  if report.Status != OutcomeSuccess && report.Status != OutcomeFailure {
44
51
  return fmt.Errorf("report status %q must be %q or %q", report.Status, OutcomeSuccess, OutcomeFailure)
45
52
  }
53
+ if report.Status == OutcomeFailure && report.NextStep == EndNode {
54
+ return fmt.Errorf("failure reports cannot select %q", EndNode)
55
+ }
46
56
  for _, field := range []struct{ name, value string }{
47
57
  {"summary.completed", report.Summary.Completed},
48
58
  {"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