relay-flow 0.2.2-alpha → 0.2.4-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 (77) hide show
  1. package/README.md +154 -13
  2. package/cmd/relay-flow/commands_test.go +5 -1
  3. package/cmd/relay-flow/main.go +27 -1
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/serve.go +14 -0
  6. package/examples/beads-workflow.yaml +3 -3
  7. package/examples/config-reference.yaml +144 -0
  8. package/examples/minimal-beads-task-workflow.yaml +34 -0
  9. package/examples/minimal-jira-task-workflow.yaml +68 -0
  10. package/examples/workflow-reference.yaml +111 -0
  11. package/internal/execution/goworkflows/activities.go +19 -0
  12. package/internal/execution/goworkflows/engine.go +20 -0
  13. package/internal/execution/goworkflows/engine_test.go +1 -1
  14. package/internal/execution/goworkflows/fakes_test.go +34 -6
  15. package/internal/execution/goworkflows/interpreter.go +48 -1
  16. package/internal/execution/goworkflows/projection.go +52 -11
  17. package/internal/execution/goworkflows/recovery_test.go +129 -0
  18. package/internal/harness/opencode/opencode.go +4 -4
  19. package/internal/harness/opencode/opencode_test.go +59 -4
  20. package/internal/harness/opencode/repo_setup.go +42 -2
  21. package/internal/harness/pi/config_test.go +46 -0
  22. package/internal/harness/pi/lifecycle_test.go +69 -0
  23. package/internal/harness/pi/pi.go +261 -0
  24. package/internal/harness/pi/pi_test.go +322 -0
  25. package/internal/harness/pi/prompt_test.go +121 -0
  26. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  27. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  28. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  29. package/internal/harness/pi/validation_test.go +168 -0
  30. package/internal/identity/identity.go +28 -1
  31. package/internal/identity/identity_test.go +40 -0
  32. package/internal/run/manager.go +193 -25
  33. package/internal/run/run.go +24 -6
  34. package/internal/run/run_manager_test.go +100 -0
  35. package/internal/runner/herdr/baseref.go +60 -0
  36. package/internal/runner/herdr/herdr.go +578 -0
  37. package/internal/runner/herdr/herdr_test.go +688 -0
  38. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  39. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  40. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  41. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  42. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  43. package/internal/runner/herdr/herdrcli/response.go +116 -0
  44. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  45. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  46. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  47. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  48. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  49. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  50. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  51. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  52. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  53. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  54. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  55. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  56. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  57. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  58. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  59. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  60. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  61. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  62. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  63. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  64. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  65. package/internal/server/api_test.go +54 -0
  66. package/internal/server/client.go +11 -0
  67. package/internal/server/fixture_test.go +18 -0
  68. package/internal/server/server.go +16 -0
  69. package/internal/task/beads/beads.go +16 -16
  70. package/internal/task/beads/config_compatibility_test.go +7 -8
  71. package/internal/task/beads/status_compatibility_test.go +43 -0
  72. package/internal/task/jira/filters_test.go +32 -6
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +43 -17
  75. package/internal/task/jira/transition_defaults_test.go +43 -0
  76. package/internal/task/task.go +8 -0
  77. package/package.json +1 -1
@@ -205,6 +205,17 @@ func (c *Client) RegisterNodeSession(ctx context.Context, registration run.NodeR
205
205
  return ack, nil
206
206
  }
207
207
 
208
+ // RestartRun creates or returns the active fresh attempt for a canceled
209
+ // ticket. The server resolves the current repo/workflow and task-system
210
+ // boundaries; the client only transports the command.
211
+ func (c *Client) RestartRun(ctx context.Context, ticket string) (run.Run, error) {
212
+ var out run.Run
213
+ if err := c.call(ctx, http.MethodPost, "/runs/by-ticket/"+url.PathEscape(ticket)+"/restart", nil, &out); err != nil {
214
+ return run.Run{}, err
215
+ }
216
+ return out, nil
217
+ }
218
+
208
219
  // CancelRun cancels the active run for the given ticket with a reason.
209
220
  func (c *Client) CancelRun(ctx context.Context, ticket, reason string) error {
210
221
  payload, _ := json.Marshal(map[string]string{"reason": reason})
@@ -34,6 +34,9 @@ type fakeServices struct {
34
34
  runtimeAck run.NodeRuntimeRegistrationAck
35
35
  processedReports map[string]bool
36
36
  submittedReports int
37
+ restartRun run.Run
38
+ restartErr error
39
+ restarts []string
37
40
  }
38
41
 
39
42
  func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
@@ -79,6 +82,21 @@ func (f *fakeServices) GetRunByTicket(_ context.Context, ticket string) (run.Run
79
82
  }
80
83
  return run.Run{}, errNotFound{ticket}
81
84
  }
85
+ func (f *fakeServices) RestartRun(_ context.Context, ticket string) (run.Run, error) {
86
+ f.restarts = append(f.restarts, ticket)
87
+ if f.restartErr != nil {
88
+ return run.Run{}, f.restartErr
89
+ }
90
+ if f.restartRun.ID != "" {
91
+ return f.restartRun, nil
92
+ }
93
+ for _, r := range f.runs {
94
+ if r.Ticket.Key == ticket {
95
+ return r, nil
96
+ }
97
+ }
98
+ return run.Run{}, errNotFound{ticket}
99
+ }
82
100
  func (f *fakeServices) CancelRun(_ context.Context, ticket, _ string) error {
83
101
  for i, r := range f.runs {
84
102
  if r.Ticket.Key == ticket {
@@ -31,6 +31,7 @@ type Deps interface {
31
31
  // Runs
32
32
  ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error)
33
33
  GetRunByTicket(ctx context.Context, ticket string) (run.Run, error)
34
+ RestartRun(ctx context.Context, ticket string) (run.Run, error)
34
35
  CancelRun(ctx context.Context, ticket, reason string) error
35
36
 
36
37
  // Reports
@@ -117,6 +118,8 @@ func writeEnv(w http.ResponseWriter, status int, env envelope) {
117
118
  // anything else is an unexpected 500.
118
119
  func mapErr(w http.ResponseWriter, err error) {
119
120
  switch {
121
+ case errors.Is(err, run.ErrRestartConflict):
122
+ writeErr(w, http.StatusConflict, "conflict", err.Error())
120
123
  case errors.Is(err, ErrNotFound):
121
124
  writeErr(w, http.StatusNotFound, "notFound", err.Error())
122
125
  case errors.Is(err, ErrConflict):
@@ -475,6 +478,7 @@ func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
475
478
 
476
479
  func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
477
480
  // /runs/by-ticket/{key} GET
481
+ // /runs/by-ticket/{key}/restart POST
478
482
  // /runs/by-ticket/{key}/cancel POST
479
483
  rest := strings.TrimPrefix(r.URL.Path, "/runs/by-ticket/")
480
484
  parts := strings.Split(rest, "/")
@@ -490,6 +494,18 @@ func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
490
494
  writeOK(w, http.StatusOK, rn)
491
495
  return
492
496
  }
497
+ if len(parts) == 2 && parts[0] != "" && parts[1] == "restart" {
498
+ if !methodOnly(w, r, http.MethodPost) {
499
+ return
500
+ }
501
+ rn, err := s.deps.RestartRun(r.Context(), parts[0])
502
+ if err != nil {
503
+ mapErr(w, err)
504
+ return
505
+ }
506
+ writeOK(w, http.StatusOK, rn)
507
+ return
508
+ }
493
509
  if len(parts) == 2 && parts[0] != "" && parts[1] == "cancel" {
494
510
  if !methodOnly(w, r, http.MethodPost) {
495
511
  return
@@ -328,9 +328,6 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
328
328
  return nil, err
329
329
  }
330
330
  f := cfg.Filters
331
- if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
332
- f.Assignees = []string{cfg.Assignee}
333
- }
334
331
  return func(ticket task.Ticket) bool {
335
332
  if len(f.ParentStatuses) > 0 && !containsExact(f.ParentStatuses, stringField(ticket.Fields, "status")) {
336
333
  return false
@@ -353,19 +350,6 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
353
350
  }, nil
354
351
  }
355
352
 
356
- func hasAssigneeFilter(raw config.RawValues) bool {
357
- switch filters := raw["filters"].(type) {
358
- case map[string]any:
359
- _, ok := filters["assignees"]
360
- return ok
361
- case config.RawValues:
362
- _, ok := filters["assignees"]
363
- return ok
364
- default:
365
- return false
366
- }
367
- }
368
-
369
353
  func containsExact(values []string, want string) bool {
370
354
  for _, value := range values {
371
355
  if value == want {
@@ -809,6 +793,21 @@ func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
809
793
  return config.Merge(builtin, inherited)
810
794
  }
811
795
 
796
+ // PrepareRestart reopens relay-owned mailbox state for a new explicit
797
+ // attempt. The parent is deliberately not changed here; the start task
798
+ // configuration checks the parent's current state and leaves a human-owned
799
+ // Blocked/Deferred/Closed status untouched. Mailbox states outside the
800
+ // relay-owned open/in_progress/closed set return a conflict.
801
+ func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox) error {
802
+ for _, mailbox := range mailboxes {
803
+ if err := s.reconcileIssue(ctx, mailbox.Key,
804
+ []string{statusOpen, statusInProgress, statusClosed}, statusOpen, ""); err != nil {
805
+ return fmt.Errorf("reopen mailbox %s for restart: %w", mailbox.Key, err)
806
+ }
807
+ }
808
+ return nil
809
+ }
810
+
812
811
  // ResetForRecovery reopens the parent and every known mailbox, clearing any
813
812
  // deferred state while preserving comments, labels, descriptions, history,
814
813
  // and issues themselves.
@@ -837,4 +836,5 @@ func (s *system) ResetForRecovery(ctx context.Context, parent task.TicketRef, ma
837
836
  var (
838
837
  _ task.System = (*system)(nil)
839
838
  _ task.LifecycleDefaults = (*system)(nil)
839
+ _ task.RestartPreparer = (*system)(nil)
840
840
  )
@@ -50,7 +50,7 @@ func TestBeadsConfigRejectsJiraOnlyFields(t *testing.T) {
50
50
  }
51
51
  }
52
52
 
53
- func TestBeadsCompileFilterUsesInheritedTopLevelAssignee(t *testing.T) {
53
+ func TestBeadsCompileFilterDoesNotUseTopLevelAssignee(t *testing.T) {
54
54
  sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
55
55
  "assignee": "Repo.Bot@Example.com",
56
56
  })}
@@ -58,15 +58,14 @@ func TestBeadsCompileFilterUsesInheritedTopLevelAssignee(t *testing.T) {
58
58
  if err != nil {
59
59
  t.Fatalf("CompileFilter failed: %v", err)
60
60
  }
61
- if !match(task.Ticket{Fields: map[string]any{"assignee": "repo.bot@example.COM"}}) {
62
- t.Fatal("top-level assignee did not become the default filter")
63
- }
64
- if match(task.Ticket{Fields: map[string]any{"assignee": "other@example.com"}}) {
65
- t.Fatal("ticket assigned to another user matched the default assignee filter")
61
+ for _, assignee := range []string{"repo.bot@example.COM", "other@example.com"} {
62
+ if !match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
63
+ t.Fatalf("assignee %q was filtered without filters.assignees", assignee)
64
+ }
66
65
  }
67
66
  }
68
67
 
69
- func TestBeadsExplicitAssigneeFilterOverridesInheritedDefault(t *testing.T) {
68
+ func TestBeadsExplicitAssigneeFilterIsUsed(t *testing.T) {
70
69
  sys := &system{base: config.Merge(DefaultConfig(), config.RawValues{
71
70
  "assignee": "repo@example.com",
72
71
  })}
@@ -84,7 +83,7 @@ func TestBeadsExplicitAssigneeFilterOverridesInheritedDefault(t *testing.T) {
84
83
  }
85
84
  for _, assignee := range []string{"repo@example.com", "workflow@example.com"} {
86
85
  if match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
87
- t.Fatalf("inherited assignee %q overrode explicit assignee filter", assignee)
86
+ t.Fatalf("explicit assignee filter accepted %q", assignee)
88
87
  }
89
88
  }
90
89
  }
@@ -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}
@@ -232,6 +232,33 @@ func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
232
232
  }
233
233
  }
234
234
 
235
+ func TestCompileFilterCurrentUserMatchesAuthenticatedJiraEmail(t *testing.T) {
236
+ sys := newSystemWithFake(t, &fakeJira{}).(*system)
237
+ sys.currentUser = "me@example.com"
238
+ match, err := sys.CompileFilter(config.RawValues{
239
+ "filters": map[string]any{"assignees": []any{"currentUser()"}},
240
+ })
241
+ if err != nil {
242
+ t.Fatalf("CompileFilter failed: %v", err)
243
+ }
244
+ if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "ME@EXAMPLE.COM"}}) {
245
+ t.Fatal("currentUser() did not resolve to the authenticated Jira email")
246
+ }
247
+ if match(task.Ticket{Key: "PAY-2", Fields: map[string]any{"assignee": "other@example.com"}}) {
248
+ t.Fatal("currentUser() matched another Jira assignee")
249
+ }
250
+ }
251
+
252
+ func TestCompileFilterCurrentUserRequiresCredentials(t *testing.T) {
253
+ sys := newSystemWithFake(t, &fakeJira{})
254
+ _, err := sys.CompileFilter(config.RawValues{
255
+ "filters": map[string]any{"assignees": []any{"currentUser()"}},
256
+ })
257
+ if err == nil || !strings.Contains(err.Error(), "authenticated Jira credentials") {
258
+ t.Fatalf("CompileFilter error = %v, want authenticated-credentials error", err)
259
+ }
260
+ }
261
+
235
262
  func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
236
263
  // Assignee is a supported structured filter dimension
237
264
  // (repo-workflow-routing: "parent statuses, issue types, labels, and
@@ -253,7 +280,7 @@ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
253
280
  }
254
281
  }
255
282
 
256
- func TestCompileFilterInheritsEffectiveAssigneeCaseInsensitively(t *testing.T) {
283
+ func TestCompileFilterDoesNotUseEffectiveAssigneeAsFilter(t *testing.T) {
257
284
  sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
258
285
  Name: "payments",
259
286
  RootConfig: config.RawValues{"assignee": "root@example.com"},
@@ -266,11 +293,10 @@ func TestCompileFilterInheritsEffectiveAssigneeCaseInsensitively(t *testing.T) {
266
293
  if err != nil {
267
294
  t.Fatal(err)
268
295
  }
269
- if !match(task.Ticket{Fields: map[string]any{"assignee": "repo.bot@example.COM"}}) {
270
- t.Fatal("effective repo assignee did not match normalized email case-insensitively")
271
- }
272
- if match(task.Ticket{Fields: map[string]any{"assignee": "other@example.com"}}) {
273
- t.Fatal("ticket assigned to another user matched effective assignee")
296
+ for _, assignee := range []string{"root@example.com", "repo.bot@example.COM", "other@example.com"} {
297
+ if !match(task.Ticket{Fields: map[string]any{"assignee": assignee}}) {
298
+ t.Fatalf("assignee %q was filtered without filters.assignees", assignee)
299
+ }
274
300
  }
275
301
  }
276
302
 
@@ -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
@@ -53,6 +53,7 @@ const (
53
53
  defaultStartParentStatus = "In Progress"
54
54
  defaultWorkTaskStatus = "In Progress"
55
55
  defaultEndParentStatus = "Done"
56
+ currentUserAssigneeFilter = "currentUser()"
56
57
  defaultMailboxDescription = `Parent ticket: {{ticket}}
57
58
  Workflow: {{workflow}}
58
59
  Node: {{node}}
@@ -147,7 +148,12 @@ func init() {
147
148
  if err != nil {
148
149
  return nil, err
149
150
  }
150
- return newSystem(ctx, client, spec)
151
+ sys, err := newSystem(ctx, client, spec)
152
+ if err != nil {
153
+ return nil, err
154
+ }
155
+ sys.currentUser = strings.TrimSpace(creds.Email)
156
+ return sys, nil
151
157
  },
152
158
  })
153
159
  }
@@ -173,10 +179,11 @@ func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
173
179
  // system is the repo-bound Jira task.System. It is safe for concurrent use;
174
180
  // the REST client owns connection reuse, caches, and request limiting.
175
181
  type system struct {
176
- cli jirarest.Client
177
- repoName string
178
- base config.RawValues
179
- effective Config // root+repo merged
182
+ cli jirarest.Client
183
+ repoName string
184
+ currentUser string
185
+ base config.RawValues
186
+ effective Config // root+repo merged
180
187
  }
181
188
 
182
189
  func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
@@ -323,9 +330,11 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
323
330
  return nil, err
324
331
  }
325
332
  f := cfg.Filters
326
- if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
327
- f.Assignees = []string{cfg.Assignee}
333
+ resolvedAssignees, err := s.resolveAssigneeFilters(f.Assignees)
334
+ if err != nil {
335
+ return nil, err
328
336
  }
337
+ f.Assignees = resolvedAssignees
329
338
  return func(t task.Ticket) bool {
330
339
  if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
331
340
  return false
@@ -348,17 +357,18 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
348
357
  }, nil
349
358
  }
350
359
 
351
- func hasAssigneeFilter(raw config.RawValues) bool {
352
- switch filters := raw["filters"].(type) {
353
- case map[string]any:
354
- _, ok := filters["assignees"]
355
- return ok
356
- case config.RawValues:
357
- _, ok := filters["assignees"]
358
- return ok
359
- default:
360
- return false
360
+ func (s *system) resolveAssigneeFilters(values []string) ([]string, error) {
361
+ resolved := make([]string, 0, len(values))
362
+ for _, value := range values {
363
+ if value == currentUserAssigneeFilter {
364
+ if s.currentUser == "" {
365
+ return nil, fmt.Errorf("jira: filters.assignees value %q requires authenticated Jira credentials", currentUserAssigneeFilter)
366
+ }
367
+ value = s.currentUser
368
+ }
369
+ resolved = append(resolved, value)
361
370
  }
371
+ return resolved, nil
362
372
  }
363
373
 
364
374
  // --- Claim ---
@@ -633,6 +643,21 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
633
643
  return s.cli.AddComment(ctx, key, body+"\n\n<!-- "+marker+" -->")
634
644
  }
635
645
 
646
+ // PrepareRestart reopens existing relay-owned mailboxes before a fresh
647
+ // explicit attempt. It does not change the parent ticket: the normal start
648
+ // ApplyTaskConfig operation owns the parent status check, so a human-owned
649
+ // parent status can put the new attempt in blocked state without being
650
+ // overwritten. Jira transition failures are classified as conflicts by
651
+ // transition and therefore retry without blind status writes.
652
+ func (s *system) PrepareRestart(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox) error {
653
+ for _, mailbox := range mailboxes {
654
+ if err := s.transition(ctx, mailbox.Key, "To Do", ""); err != nil {
655
+ return fmt.Errorf("reopen mailbox %s for restart: %w", mailbox.Key, err)
656
+ }
657
+ }
658
+ return nil
659
+ }
660
+
636
661
  // --- Recovery ---
637
662
 
638
663
  // ResetForRecovery resets mailbox subtasks to To Do while preserving
@@ -677,4 +702,5 @@ func strSliceField(fields map[string]any, key string) []string {
677
702
  var (
678
703
  _ task.System = (*system)(nil)
679
704
  _ task.LifecycleDefaults = (*system)(nil)
705
+ _ task.RestartPreparer = (*system)(nil)
680
706
  )
@@ -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.4-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",