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.
Files changed (52) hide show
  1. package/README.md +27 -19
  2. package/cmd/relay-flow/commands_test.go +440 -21
  3. package/cmd/relay-flow/main.go +203 -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/internal/execution/goworkflows/activities.go +11 -0
  10. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  11. package/internal/execution/goworkflows/engine.go +278 -9
  12. package/internal/execution/goworkflows/fakes_test.go +30 -1
  13. package/internal/execution/goworkflows/interpreter.go +18 -1
  14. package/internal/execution/goworkflows/ownership_test.go +259 -0
  15. package/internal/execution/goworkflows/projection.go +8 -0
  16. package/internal/execution/goworkflows/recovery_test.go +563 -1
  17. package/internal/execution/projection/detail_test.go +38 -0
  18. package/internal/execution/projection/projection.go +80 -15
  19. package/internal/execution/temporal/interpreter.go +2 -1
  20. package/internal/execution/temporal/recovery.go +68 -36
  21. package/internal/harness/harness.go +1 -0
  22. package/internal/harness/opencode/opencode.go +5 -0
  23. package/internal/harness/opencode/opencode_test.go +17 -1
  24. package/internal/harness/opencode/repo_setup.go +1 -1
  25. package/internal/harness/pi/pi.go +6 -0
  26. package/internal/harness/pi/task_env_test.go +16 -0
  27. package/internal/recover/recover.go +30 -1
  28. package/internal/repo/binding_test.go +63 -0
  29. package/internal/repo/repo.go +47 -6
  30. package/internal/router/router.go +39 -5
  31. package/internal/router/router_test.go +67 -1
  32. package/internal/run/manager.go +90 -3
  33. package/internal/run/run_manager_test.go +74 -0
  34. package/internal/server/api_test.go +13 -0
  35. package/internal/server/client.go +7 -0
  36. package/internal/server/fixture_test.go +5 -0
  37. package/internal/server/server.go +32 -3
  38. package/internal/task/beads/beads.go +201 -3
  39. package/internal/task/beads/beads_test.go +172 -0
  40. package/internal/task/jira/effects_test.go +86 -0
  41. package/internal/task/jira/filters_test.go +103 -0
  42. package/internal/task/jira/helpers_test.go +11 -2
  43. package/internal/task/jira/jira.go +228 -4
  44. package/internal/task/jira/normalize.go +30 -15
  45. package/internal/task/jira/rest/client.go +17 -2
  46. package/internal/task/jira/transition_defaults_test.go +3 -1
  47. package/internal/task/task.go +66 -0
  48. package/internal/workflow/report.go +3 -0
  49. package/internal/workflow/report_test.go +16 -0
  50. package/internal/workflow/workflow.go +24 -0
  51. package/internal/workflow/workflow_test.go +58 -1
  52. 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 = (*system)(nil)
921
- _ task.LifecycleDefaults = (*system)(nil)
922
- _ task.RestartPreparer = (*system)(nil)
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
- 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
 
@@ -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
  )
@@ -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: end\n when: give up", 1)
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.3.8-alpha",
3
+ "version": "0.3.10-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",