relay-flow 0.2.2-alpha → 0.2.3-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.
@@ -94,6 +94,49 @@ func TestApplyTaskConfigReopensClosedMailboxOnWorkflowRevisit(t *testing.T) {
94
94
  }
95
95
  }
96
96
 
97
+ func TestPrepareRestartReopensRelayOwnedMailboxes(t *testing.T) {
98
+ client := newStatusClient(map[string]string{
99
+ "demo-parent.1": "closed",
100
+ "demo-parent.2": "in_progress",
101
+ })
102
+ sys := &system{cli: client}
103
+ preparer, ok := task.System(sys).(task.RestartPreparer)
104
+ if !ok {
105
+ t.Fatal("Beads system does not implement RestartPreparer")
106
+ }
107
+ mailboxes := []task.Mailbox{
108
+ {ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
109
+ {ID: "demo-parent.2", Key: "demo-parent.2", Node: "review"},
110
+ }
111
+ if err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "demo-parent"}, mailboxes); err != nil {
112
+ t.Fatalf("PrepareRestart failed: %v", err)
113
+ }
114
+ if len(client.updates) != 2 || client.updates[0].input.Status != "open" || client.updates[1].input.Status != "open" {
115
+ t.Fatalf("updates = %+v, want both relay-owned mailboxes reopened to open", client.updates)
116
+ }
117
+ if client.issues["demo-parent"].Status != "" {
118
+ t.Fatalf("parent was unexpectedly inspected or changed: %+v", client.issues["demo-parent"])
119
+ }
120
+ }
121
+
122
+ func TestPrepareRestartDoesNotOverwriteHumanMailboxState(t *testing.T) {
123
+ client := newStatusClient(map[string]string{"demo-parent.1": "blocked"})
124
+ sys := &system{cli: client}
125
+ preparer := task.System(sys).(task.RestartPreparer)
126
+ err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "demo-parent"}, []task.Mailbox{
127
+ {ID: "demo-parent.1", Key: "demo-parent.1", Node: "implement"},
128
+ })
129
+ if err == nil {
130
+ t.Fatal("human-owned blocked mailbox state was overwritten")
131
+ }
132
+ if got := retry.Classify(err).Kind; got != retry.Conflict {
133
+ t.Fatalf("failure kind = %q, want conflict: %v", got, err)
134
+ }
135
+ if len(client.updates) != 0 {
136
+ t.Fatalf("human-owned mailbox state issued an update: %+v", client.updates)
137
+ }
138
+ }
139
+
97
140
  func TestApplyTaskConfigRejectsIncompatibleManualMailboxState(t *testing.T) {
98
141
  client := newStatusClient(map[string]string{"demo-parent.1": "blocked"})
99
142
  sys := &system{cli: client}
@@ -37,6 +37,9 @@ func (f *fakeClient) Transition(_ context.Context, key, status, assignee string)
37
37
  return f.fake.assignErr
38
38
  }
39
39
  }
40
+ if f.fake.transitionErr != nil {
41
+ return f.fake.transitionErr
42
+ }
40
43
  f.fake.events = append(f.fake.events, "transition")
41
44
  f.fake.transition(key, status)
42
45
  return nil
@@ -633,6 +633,21 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
633
633
  return s.cli.AddComment(ctx, key, body+"\n\n<!-- "+marker+" -->")
634
634
  }
635
635
 
636
+ // PrepareRestart reopens existing relay-owned mailboxes before a fresh
637
+ // explicit attempt. It does not change the parent ticket: the normal start
638
+ // ApplyTaskConfig operation owns the parent status check, so a human-owned
639
+ // parent status can put the new attempt in blocked state without being
640
+ // overwritten. Jira transition failures are classified as conflicts by
641
+ // transition and therefore retry without blind status writes.
642
+ func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox) error {
643
+ for _, mailbox := range mailboxes {
644
+ if err := s.transition(ctx, mailbox.Key, "To Do", ""); err != nil {
645
+ return fmt.Errorf("reopen mailbox %s for restart: %w", mailbox.Key, err)
646
+ }
647
+ }
648
+ return nil
649
+ }
650
+
636
651
  // --- Recovery ---
637
652
 
638
653
  // ResetForRecovery resets mailbox subtasks to To Do while preserving
@@ -677,4 +692,5 @@ func strSliceField(fields map[string]any, key string) []string {
677
692
  var (
678
693
  _ task.System = (*system)(nil)
679
694
  _ task.LifecycleDefaults = (*system)(nil)
695
+ _ task.RestartPreparer = (*system)(nil)
680
696
  )
@@ -6,6 +6,7 @@ import (
6
6
  "testing"
7
7
 
8
8
  "github.com/rajpopat27/relay-flow/internal/config"
9
+ "github.com/rajpopat27/relay-flow/internal/retry"
9
10
  "github.com/rajpopat27/relay-flow/internal/task"
10
11
  )
11
12
 
@@ -27,6 +28,7 @@ type fakeJira struct {
27
28
  taskTransitions []string
28
29
  assignments []string
29
30
  assignErr error
31
+ transitionErr error
30
32
  events []string
31
33
  // searchJSON is the raw Jira search response Poll serves.
32
34
  searchJSON []byte
@@ -139,6 +141,47 @@ func TestEndDefaultParentDone(t *testing.T) {
139
141
  }
140
142
  }
141
143
 
144
+ func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
145
+ fake := &fakeJira{}
146
+ sys := newSystemWithFake(t, fake)
147
+ preparer, ok := sys.(task.RestartPreparer)
148
+ if !ok {
149
+ t.Fatal("Jira system does not implement RestartPreparer")
150
+ }
151
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
152
+ mailboxes := []task.Mailbox{
153
+ {ID: "2", Key: "PAY-102", Node: "coding"},
154
+ {ID: "3", Key: "PAY-103", Node: "review"},
155
+ }
156
+ if err := preparer.PrepareRestart(context.Background(), parent, mailboxes); err != nil {
157
+ t.Fatalf("PrepareRestart failed: %v", err)
158
+ }
159
+ if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "To Do" || fake.taskTransitions[1] != "To Do" {
160
+ t.Fatalf("mailbox transitions = %v, want two To Do transitions", fake.taskTransitions)
161
+ }
162
+ if len(fake.parentTransitions) != 0 {
163
+ t.Fatalf("parent transitions = %v, want none (start owns parent status)", fake.parentTransitions)
164
+ }
165
+ }
166
+
167
+ func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
168
+ fake := &fakeJira{transitionErr: errors.New(`transition to "To Do" is not available for PAY-102`)}
169
+ sys := newSystemWithFake(t, fake)
170
+ preparer := sys.(task.RestartPreparer)
171
+ err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "PAY-101"}, []task.Mailbox{
172
+ {ID: "2", Key: "PAY-102", Node: "coding"},
173
+ })
174
+ if err == nil {
175
+ t.Fatal("incompatible Jira mailbox status was accepted")
176
+ }
177
+ if got := retry.Classify(err).Kind; got != retry.Conflict {
178
+ t.Fatalf("failure kind = %q, want conflict: %v", got, err)
179
+ }
180
+ if len(fake.parentTransitions) != 0 {
181
+ t.Fatalf("parent transitions = %v, want none", fake.parentTransitions)
182
+ }
183
+ }
184
+
142
185
  func TestExplicitTransitionsWin(t *testing.T) {
143
186
  fake := &fakeJira{}
144
187
  sys := newSystemWithFake(t, fake)
@@ -99,6 +99,14 @@ type System interface {
99
99
  ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
100
100
  }
101
101
 
102
+ // RestartPreparer is an optional adapter capability used only by explicit
103
+ // run restarts. It reopens relay-owned mailbox state while preserving all
104
+ // comments, labels, and descriptions. A human-owned incompatible state must
105
+ // be returned as retry.ConflictError; core never names provider statuses.
106
+ type RestartPreparer interface {
107
+ PrepareRestart(ctx context.Context, parent TicketRef, mailboxes []Mailbox) error
108
+ }
109
+
102
110
  // LifecycleDefaults is an optional adapter capability: a System whose
103
111
  // taskConfig carries lifecycle-dependent defaults (e.g. Jira's deterministic
104
112
  // transitionTo defaults) exposes them here. The lifecycle-aware caller
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.2-alpha",
3
+ "version": "0.2.3-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",