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.
- package/README.md +38 -3
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +25 -1
- package/cmd/relay-flow/serve.go +12 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -0
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +16 -0
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- package/package.json +1 -1
|
@@ -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)
|
package/internal/task/task.go
CHANGED
|
@@ -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
|