relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -6,13 +6,15 @@ import (
6
6
  "context"
7
7
  "fmt"
8
8
  "log/slog"
9
+ "regexp"
9
10
  "sort"
10
11
  "strings"
12
+ "sync"
11
13
 
12
14
  "github.com/rajpopat27/relay-flow/internal/config"
13
15
  "github.com/rajpopat27/relay-flow/internal/retry"
14
16
  "github.com/rajpopat27/relay-flow/internal/task"
15
- "github.com/rajpopat27/relay-flow/internal/task/jira/acli"
17
+ jirarest "github.com/rajpopat27/relay-flow/internal/task/jira/rest"
16
18
  )
17
19
 
18
20
  // Config is the adapter-owned typed config for every scope.
@@ -20,9 +22,16 @@ type Config struct {
20
22
  Assignee string `yaml:"assignee,omitempty"`
21
23
  Project string `yaml:"project,omitempty"`
22
24
  Component string `yaml:"component,omitempty"`
23
- Site string `yaml:"site,omitempty"`
24
25
  Filters Filters `yaml:"filters,omitempty"`
25
26
  Transition TransitionTo `yaml:"transitionTo,omitempty"`
27
+ Templates Templates `yaml:"templates,omitempty"`
28
+ }
29
+
30
+ // Templates are Jira-owned task-system descriptions and comments.
31
+ type Templates struct {
32
+ MailboxDescription string `yaml:"mailboxDescription"`
33
+ SummaryComment string `yaml:"summaryComment"`
34
+ FeedbackComment string `yaml:"feedbackComment"`
26
35
  }
27
36
 
28
37
  // Filters are the structured, locally evaluable workflow ticket matchers.
@@ -41,9 +50,53 @@ type TransitionTo struct {
41
50
 
42
51
  // Deterministic transition defaults for omitted values.
43
52
  const (
44
- defaultStartParentStatus = "In Progress"
45
- defaultWorkTaskStatus = "In Progress"
46
- defaultEndParentStatus = "Done"
53
+ defaultStartParentStatus = "In Progress"
54
+ defaultWorkTaskStatus = "In Progress"
55
+ defaultEndParentStatus = "Done"
56
+ defaultMailboxDescription = `Parent ticket: {{ticket}}
57
+ Workflow: {{workflow}}
58
+ Node: {{node}}
59
+ Node type: {{nodeType}}
60
+ Agent: {{agent}}
61
+ Mailbox: {{mailbox}}
62
+
63
+ Node work:
64
+ {{nodeDescription}}
65
+
66
+ Read this mailbox's comments for feedback from previous nodes.`
67
+ defaultSummaryComment = `Summary for {{node}}
68
+
69
+ {{summaryReport}}`
70
+ defaultFeedbackComment = `Feedback from {{sourceNode}} to {{targetNode}} mailbox {{mailbox}}
71
+
72
+ {{feedbackReport}}`
73
+ )
74
+
75
+ var textVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
76
+
77
+ var knownTextVars = map[string]bool{
78
+ "runID": true, "ticket": true, "workflow": true,
79
+ "repo": true, "node": true, "nodeType": true, "agent": true,
80
+ "nodeDescription": true, "nextSteps": true, "successRoutes": true,
81
+ "failureRoutes": true, "mailbox": true, "sourceNode": true,
82
+ "targetNode": true, "summaryReport": true, "feedbackReport": true,
83
+ }
84
+
85
+ // DefaultConfig supplies Jira task-system text defaults for relay-flow init.
86
+ func DefaultConfig() config.RawValues {
87
+ return config.RawValues{"templates": map[string]any{
88
+ "mailboxDescription": defaultMailboxDescription,
89
+ "summaryComment": defaultSummaryComment,
90
+ "feedbackComment": defaultFeedbackComment,
91
+ }}
92
+ }
93
+
94
+ var (
95
+ clientsMu sync.Mutex
96
+ clients = map[string]struct {
97
+ token string
98
+ client *jirarest.HTTPClient
99
+ }{}
47
100
  )
48
101
 
49
102
  // claimLabel is the permanent workflow claim label.
@@ -52,6 +105,14 @@ func claimLabel(workflow string) string { return "wf:" + workflow }
52
105
  func init() {
53
106
  task.Register("jira", task.Factory{
54
107
  RequiredRepoKeys: func() []string { return []string{"project", "component"} },
108
+ DefaultConfig: DefaultConfig,
109
+ ValidateTextConfig: func(raw config.RawValues) error {
110
+ var cfg Config
111
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
112
+ return err
113
+ }
114
+ return validateTemplates(cfg.Templates)
115
+ },
55
116
  TaskScopeKey: func(rootConfig, repoConfig config.RawValues) (string, error) {
56
117
  var root, repoCfg Config
57
118
  if err := config.DecodeStrict(rootConfig, &root); err != nil {
@@ -65,34 +126,73 @@ func init() {
65
126
  if proj == "" || comp == "" {
66
127
  return "", fmt.Errorf("jira task scope requires repo project and component")
67
128
  }
68
- return strings.Join([]string{root.Site, proj, comp}, "/"), nil
129
+ creds, err := loadCredentialsDefault()
130
+ if err != nil {
131
+ return "", fmt.Errorf("jira credentials: %w", err)
132
+ }
133
+ return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
69
134
  },
135
+ Auth: auth,
70
136
  New: func(ctx context.Context, spec task.RepoSpec) (task.System, error) {
71
- return newSystem(ctx, acli.New(), spec)
137
+ merged := config.Merge(spec.RootConfig, spec.RepoConfig)
138
+ var cfg Config
139
+ if err := config.DecodeStrict(merged, &cfg); err != nil {
140
+ return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
141
+ }
142
+ creds, err := loadCredentialsDefault()
143
+ if err != nil {
144
+ return nil, fmt.Errorf("jira credentials: %w", err)
145
+ }
146
+ client, err := sharedClient(creds.Site, creds.Email, creds.Token)
147
+ if err != nil {
148
+ return nil, err
149
+ }
150
+ return newSystem(ctx, client, spec)
72
151
  },
73
152
  })
74
153
  }
75
154
 
155
+ func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
156
+ key := strings.TrimRight(site, "/") + "\x00" + email
157
+ clientsMu.Lock()
158
+ defer clientsMu.Unlock()
159
+ if cached := clients[key]; cached.client != nil && cached.token == token {
160
+ return cached.client, nil
161
+ }
162
+ client, err := jirarest.New(site, email, token)
163
+ if err != nil {
164
+ return nil, err
165
+ }
166
+ clients[key] = struct {
167
+ token string
168
+ client *jirarest.HTTPClient
169
+ }{token: token, client: client}
170
+ return client, nil
171
+ }
172
+
76
173
  // system is the repo-bound Jira task.System. It is safe for concurrent use;
77
- // the ACLI client owns any subprocess serialization.
174
+ // the REST client owns connection reuse, caches, and request limiting.
78
175
  type system struct {
79
- cli acli.Client
176
+ cli jirarest.Client
80
177
  repoName string
81
178
  base config.RawValues
82
179
  effective Config // root+repo merged
83
180
  }
84
181
 
85
- func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*system, error) {
182
+ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
86
183
  if spec.Name == "" {
87
184
  return nil, fmt.Errorf("jira: repo name is required")
88
185
  }
89
- merged := config.Merge(spec.RootConfig, spec.RepoConfig)
186
+ merged := config.Merge(DefaultConfig(), spec.RootConfig, spec.RepoConfig)
90
187
  var cfg Config
91
188
  if err := config.DecodeStrict(merged, &cfg); err != nil {
92
189
  return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
93
190
  }
191
+ if err := validateTemplates(cfg.Templates); err != nil {
192
+ return nil, fmt.Errorf("jira repo %q taskConfig.templates: %w", spec.Name, err)
193
+ }
94
194
  if cfg.Assignee != "" {
95
- if err := cli.ValidateAssignee(ctx, cfg.Assignee); err != nil {
195
+ if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
96
196
  return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
97
197
  }
98
198
  }
@@ -110,11 +210,10 @@ func newSystem(ctx context.Context, cli acli.Client, spec task.RepoSpec) (*syste
110
210
  return s, nil
111
211
  }
112
212
 
113
- // newSystemForCLI constructs a system around an explicit CLI seam (tests).
114
- func newSystemForCLI(cli acli.Client) (task.System, error) {
213
+ // newSystemForCLI constructs a system around an explicit Jira client seam.
214
+ func newSystemForCLI(cli jirarest.Client) (task.System, error) {
115
215
  return newSystem(context.Background(), cli, task.RepoSpec{
116
216
  Name: "payments",
117
- RootConfig: config.RawValues{},
118
217
  RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
119
218
  })
120
219
  }
@@ -128,6 +227,55 @@ func decodeConfig(raw config.RawValues) (Config, error) {
128
227
  return cfg, nil
129
228
  }
130
229
 
230
+ func validateTemplates(templates Templates) error {
231
+ for name, tmpl := range map[string]string{
232
+ "mailboxDescription": templates.MailboxDescription,
233
+ "summaryComment": templates.SummaryComment,
234
+ "feedbackComment": templates.FeedbackComment,
235
+ } {
236
+ for _, match := range textVarPattern.FindAllStringSubmatch(tmpl, -1) {
237
+ if !knownTextVars[match[1]] {
238
+ return fmt.Errorf("%s: unknown template variable {{%s}}", name, match[1])
239
+ }
240
+ }
241
+ }
242
+ if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
243
+ return fmt.Errorf("summaryComment must contain {{summaryReport}}")
244
+ }
245
+ if !strings.Contains(templates.FeedbackComment, "{{feedbackReport}}") {
246
+ return fmt.Errorf("feedbackComment must contain {{feedbackReport}}")
247
+ }
248
+ return nil
249
+ }
250
+
251
+ // RenderText renders Jira-owned mailbox descriptions and comments.
252
+ func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, error) {
253
+ var tmpl string
254
+ switch kind {
255
+ case task.TextMailboxDescription:
256
+ tmpl = s.effective.Templates.MailboxDescription
257
+ case task.TextSummaryComment:
258
+ tmpl = s.effective.Templates.SummaryComment
259
+ case task.TextFeedbackComment:
260
+ tmpl = s.effective.Templates.FeedbackComment
261
+ default:
262
+ return "", fmt.Errorf("jira: unknown task text kind %q", kind)
263
+ }
264
+ values := map[string]string{
265
+ "runID": data.RunID, "ticket": data.Ticket,
266
+ "workflow": data.Workflow, "repo": data.Repo, "node": data.Node,
267
+ "nodeType": data.NodeType, "agent": data.Agent,
268
+ "nodeDescription": data.NodeDescription, "nextSteps": data.NextSteps,
269
+ "successRoutes": data.SuccessRoutes, "failureRoutes": data.FailureRoutes,
270
+ "mailbox": data.Mailbox, "sourceNode": data.SourceNode,
271
+ "targetNode": data.TargetNode, "summaryReport": data.SummaryReport,
272
+ "feedbackReport": data.FeedbackReport,
273
+ }
274
+ return textVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
275
+ return values[textVarPattern.FindStringSubmatch(match)[1]]
276
+ }), nil
277
+ }
278
+
131
279
  // --- Poll / filters ---
132
280
 
133
281
  // buildJQL scopes the parent poll by project/component and active statuses.
@@ -169,11 +317,15 @@ func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
169
317
  // CompileFilter compiles workflow taskConfig.filters into an in-memory
170
318
  // matcher over normalized ticket fields. Unknown filter fields are rejected.
171
319
  func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
172
- cfg, err := decodeConfig(workflowTaskConfig)
320
+ merged := config.Merge(s.base, workflowTaskConfig)
321
+ cfg, err := decodeConfig(merged)
173
322
  if err != nil {
174
323
  return nil, err
175
324
  }
176
325
  f := cfg.Filters
326
+ if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
327
+ f.Assignees = []string{cfg.Assignee}
328
+ }
177
329
  return func(t task.Ticket) bool {
178
330
  if len(f.ParentStatuses) > 0 && !contains(f.ParentStatuses, strField(t.Fields, "status")) {
179
331
  return false
@@ -189,36 +341,32 @@ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.T
189
341
  }
190
342
  }
191
343
  }
192
- if len(f.Assignees) > 0 && !contains(f.Assignees, strField(t.Fields, "assignee")) {
344
+ if len(f.Assignees) > 0 && !containsFold(f.Assignees, strField(t.Fields, "assignee")) {
193
345
  return false
194
346
  }
195
347
  return true
196
348
  }, nil
197
349
  }
198
350
 
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
361
+ }
362
+ }
363
+
199
364
  // --- Claim ---
200
365
 
201
- // Claim adds wf:<workflow> to the parent. It re-reads the parent's claims,
202
- // treats the same claim as idempotent, and rejects a conflicting claim.
366
+ // Claim adds wf:<workflow> using the claims already inspected by routing.
367
+ // Jira's label-add operation is idempotent.
203
368
  func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
204
- raw, err := s.cli.View(ctx, ticket.Key)
205
- if err != nil {
206
- return err
207
- }
208
- labels, err := labelsOf(raw)
209
- if err != nil {
210
- return err
211
- }
212
- want := claimLabel(workflow)
213
- for _, l := range labels {
214
- if l == want {
215
- return nil // idempotent
216
- }
217
- if strings.HasPrefix(l, "wf:") {
218
- return fmt.Errorf("ticket %s already claimed by %s", ticket.Key, l)
219
- }
220
- }
221
- return s.cli.EnsureLabel(ctx, ticket.Key, want)
369
+ return s.cli.EnsureLabel(ctx, ticket.Key, claimLabel(workflow))
222
370
  }
223
371
 
224
372
  // --- Config validation ---
@@ -231,7 +379,10 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
231
379
  if err != nil {
232
380
  return fmt.Errorf("workflow taskConfig: %w", err)
233
381
  }
234
- if err := s.validateAssignee(ctx, "workflow", workflowCfg.Assignee); err != nil {
382
+ if err := validateTemplates(workflowCfg.Templates); err != nil {
383
+ return fmt.Errorf("workflow taskConfig.templates: %w", err)
384
+ }
385
+ if err := s.validateAssignee(ctx, "workflow", workflowCfg.Project, workflowCfg.Assignee); err != nil {
235
386
  return err
236
387
  }
237
388
  if err := s.validateTransition(ctx, "workflow", workflowCfg.Project, workflowCfg.Transition); err != nil {
@@ -247,7 +398,10 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
247
398
  if err != nil {
248
399
  return fmt.Errorf("node %q taskConfig: %w", n, err)
249
400
  }
250
- if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Assignee); err != nil {
401
+ if err := validateTemplates(cfg.Templates); err != nil {
402
+ return fmt.Errorf("node %q taskConfig.templates: %w", n, err)
403
+ }
404
+ if err := s.validateAssignee(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Assignee); err != nil {
251
405
  return err
252
406
  }
253
407
  if err := s.validateTransition(ctx, fmt.Sprintf("node %q", n), cfg.Project, cfg.Transition); err != nil {
@@ -257,11 +411,11 @@ func (s *system) ValidateConfig(ctx context.Context, workflowTaskConfig config.R
257
411
  return nil
258
412
  }
259
413
 
260
- func (s *system) validateAssignee(ctx context.Context, scope, assignee string) error {
414
+ func (s *system) validateAssignee(ctx context.Context, scope, project, assignee string) error {
261
415
  if assignee == "" {
262
416
  return nil
263
417
  }
264
- if err := s.cli.ValidateAssignee(ctx, assignee); err != nil {
418
+ if err := s.cli.ValidateAssignee(ctx, project, assignee); err != nil {
265
419
  return fmt.Errorf("%s assignee %q: %w", scope, assignee, err)
266
420
  }
267
421
  return nil
@@ -289,47 +443,47 @@ func (s *system) validateTransition(ctx context.Context, scope, project string,
289
443
  // LifecycleDefaults exposure: the deterministic Jira transition defaults per
290
444
  // lifecycle point (spec: Jira transition defaults are deterministic). Run
291
445
  // orchestration merges these raw values under the effective node config
292
- // before ApplyTaskConfig; omitted values inherit the default, explicit
293
- // values win (map merge with defaults as the lower layer).
446
+ // before ApplyTaskConfig, so the effective precedence is
447
+ // built-in default < root < repo < workflow < node.
448
+ //
449
+ // The methods also carry the inherited root/repository transitionTo and
450
+ // assignee values. ApplyTaskConfig receives only the workflow/node config the
451
+ // caller assembles, so returning inherited values here is what makes a
452
+ // repo-scoped value take effect at runtime instead of being validated and then
453
+ // silently discarded. internal/task/beads implements the same rule; keep the
454
+ // two adapters aligned.
294
455
 
295
456
  // StartDefaults defaults the parent to In Progress.
296
457
  func (s *system) StartDefaults() config.RawValues {
297
- return config.RawValues{"transitionTo": map[string]any{"parentStatus": defaultStartParentStatus}}
458
+ return s.lifecycleDefaults(transitionDefault("parentStatus", defaultStartParentStatus))
298
459
  }
299
460
 
300
461
  // WorkDefaults defaults the mailbox task status to In Progress; the parent
301
462
  // is left unchanged when parentStatus is omitted.
302
463
  func (s *system) WorkDefaults() config.RawValues {
303
- return config.RawValues{"transitionTo": map[string]any{"taskStatus": defaultWorkTaskStatus}}
464
+ return s.lifecycleDefaults(transitionDefault("taskStatus", defaultWorkTaskStatus))
304
465
  }
305
466
 
306
467
  // EndDefaults defaults the parent to Done.
307
468
  func (s *system) EndDefaults() config.RawValues {
308
- return config.RawValues{"transitionTo": map[string]any{"parentStatus": defaultEndParentStatus}}
469
+ return s.lifecycleDefaults(transitionDefault("parentStatus", defaultEndParentStatus))
309
470
  }
310
471
 
311
- // endConfig applies the deterministic end default: omitted parentStatus
312
- // becomes Done.
313
- func endConfig(cfg config.RawValues) config.RawValues {
314
- return withTransitionDefault(cfg, "parentStatus", defaultEndParentStatus)
472
+ func transitionDefault(key, value string) config.RawValues {
473
+ return config.RawValues{"transitionTo": map[string]any{key: value}}
315
474
  }
316
475
 
317
- func withTransitionDefault(cfg config.RawValues, key, value string) config.RawValues {
318
- out := config.RawValues{}
319
- for k, v := range cfg {
320
- out[k] = v
321
- }
322
- merged := map[string]any{}
323
- if tr, ok := out["transitionTo"].(map[string]any); ok {
324
- for k, v := range tr {
325
- merged[k] = v
476
+ // lifecycleDefaults layers the inherited root/repository operation values over
477
+ // the built-in lifecycle default. Only the keys ApplyTaskConfig consumes are
478
+ // carried, so unrelated configuration never enters durable activity inputs.
479
+ func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
480
+ inherited := config.RawValues{}
481
+ for _, key := range []string{"transitionTo", "assignee"} {
482
+ if value, ok := s.base[key]; ok {
483
+ inherited[key] = value
326
484
  }
327
485
  }
328
- if _, ok := merged[key]; !ok {
329
- merged[key] = value
330
- }
331
- out["transitionTo"] = merged
332
- return out
486
+ return config.Merge(builtin, inherited)
333
487
  }
334
488
 
335
489
  // --- Mailboxes ---
@@ -347,28 +501,38 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
347
501
  return nil, err
348
502
  }
349
503
  out := map[string]task.Mailbox{}
504
+ missing := make([]task.MailboxSpec, 0)
350
505
  for _, spec := range specs {
351
506
  if mb, ok := existing[spec.Title]; ok {
352
- // Found existing: reconcile the workflow label and description,
353
- // and return the complete node-identified value.
354
- if err := s.cli.EnsureLabel(ctx, mb.Key, claimLabel(workflow)); err != nil {
355
- return nil, fmt.Errorf("label mailbox %q: %w", mb.Key, err)
356
- }
357
- if err := s.cli.UpdateDescription(ctx, mb.Key, spec.Description); err != nil {
358
- return nil, fmt.Errorf("describe mailbox %q: %w", mb.Key, err)
507
+ if err := s.cli.UpdateMailbox(ctx, mb.Key, spec.Description, claimLabel(workflow)); err != nil {
508
+ return nil, fmt.Errorf("reconcile mailbox %q: %w", mb.Key, err)
359
509
  }
360
510
  mb.Node = spec.Node
361
511
  out[spec.Node] = mb
362
512
  continue
363
513
  }
364
- id, key, err := s.cli.CreateSubtask(ctx, parent.Key, spec.Title, spec.Description)
365
- if err != nil {
366
- return nil, fmt.Errorf("create mailbox %q: %w", spec.Title, err)
367
- }
368
- if err := s.cli.EnsureLabel(ctx, key, claimLabel(workflow)); err != nil {
369
- return nil, fmt.Errorf("label mailbox %q: %w", key, err)
514
+ missing = append(missing, spec)
515
+ }
516
+ if len(missing) == 0 {
517
+ return out, nil
518
+ }
519
+ createSpecs := make([]jirarest.SubtaskSpec, 0, len(missing))
520
+ for _, spec := range missing {
521
+ createSpecs = append(createSpecs, jirarest.SubtaskSpec{Title: spec.Title, Description: spec.Description})
522
+ }
523
+ created, err := s.cli.CreateSubtasks(ctx, parent.Key, s.effective.Project, claimLabel(workflow), createSpecs)
524
+ if err != nil {
525
+ return nil, fmt.Errorf("create mailboxes: %w", err)
526
+ }
527
+ if len(created) != len(missing) {
528
+ return nil, fmt.Errorf("create mailboxes: created %d of %d", len(created), len(missing))
529
+ }
530
+ for i, mailbox := range created {
531
+ spec := missing[i]
532
+ if mailbox.ID == "" || mailbox.Key == "" {
533
+ return nil, fmt.Errorf("create mailbox %q: Jira returned no id/key", spec.Title)
370
534
  }
371
- out[spec.Node] = task.Mailbox{ID: id, Key: key, Node: spec.Node}
535
+ out[spec.Node] = task.Mailbox{ID: mailbox.ID, Key: mailbox.Key, Node: spec.Node}
372
536
  }
373
537
  return out, nil
374
538
  }
@@ -388,20 +552,15 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
388
552
  }
389
553
  tr := cfg.Transition
390
554
  if target.Mailbox != nil {
391
- if cfg.Assignee != "" {
392
- if err := s.cli.Assign(ctx, target.Mailbox.Key, cfg.Assignee); err != nil {
393
- return err
394
- }
395
- }
396
555
  status := tr.TaskStatus
397
556
  if status == "" {
398
557
  status = defaultWorkTaskStatus
399
558
  }
400
- if err := s.transition(ctx, target.Mailbox.Key, status); err != nil {
559
+ if err := s.transition(ctx, target.Mailbox.Key, status, cfg.Assignee); err != nil {
401
560
  return err
402
561
  }
403
562
  if tr.ParentStatus != "" {
404
- return s.transition(ctx, target.Parent.Key, tr.ParentStatus)
563
+ return s.transition(ctx, target.Parent.Key, tr.ParentStatus, "")
405
564
  }
406
565
  return nil
407
566
  }
@@ -410,13 +569,13 @@ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskCo
410
569
  if status == "" {
411
570
  status = defaultStartParentStatus
412
571
  }
413
- return s.transition(ctx, target.Parent.Key, status)
572
+ return s.transition(ctx, target.Parent.Key, status, "")
414
573
  }
415
574
 
416
575
  // transition applies a Jira status transition, mapping a human-incompatible
417
576
  // current state to a conflict.
418
- func (s *system) transition(ctx context.Context, key, status string) error {
419
- err := s.cli.Transition(ctx, key, status)
577
+ func (s *system) transition(ctx context.Context, key, status, assignee string) error {
578
+ err := s.cli.Transition(ctx, key, status, assignee)
420
579
  if err != nil && isConflict(err) {
421
580
  return retry.ConflictError(err)
422
581
  }
@@ -431,7 +590,7 @@ func isConflict(err error) bool {
431
590
 
432
591
  // CompleteMailbox marks the mailbox Done using task-system semantics.
433
592
  func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
434
- return s.transition(ctx, mailbox.Key, "Done")
593
+ return s.transition(ctx, mailbox.Key, "Done", "")
435
594
  }
436
595
 
437
596
  // --- Comments ---
@@ -480,7 +639,7 @@ func (s *system) Comment(ctx context.Context, target task.Target, body, marker s
480
639
  // comments, labels, and history. No parent rollback runs.
481
640
  func (s *system) ResetForRecovery(ctx context.Context, _ task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
482
641
  for _, mb := range mailboxes {
483
- if err := s.transition(ctx, mb.Key, "To Do"); err != nil {
642
+ if err := s.transition(ctx, mb.Key, "To Do", ""); err != nil {
484
643
  return fmt.Errorf("reset mailbox %s: %w", mb.Key, err)
485
644
  }
486
645
  }
@@ -496,6 +655,15 @@ func contains(xs []string, want string) bool {
496
655
  return false
497
656
  }
498
657
 
658
+ func containsFold(xs []string, want string) bool {
659
+ for _, x := range xs {
660
+ if strings.EqualFold(x, want) {
661
+ return true
662
+ }
663
+ }
664
+ return false
665
+ }
666
+
499
667
  func strField(fields map[string]any, key string) string {
500
668
  s, _ := fields[key].(string)
501
669
  return s
@@ -505,3 +673,8 @@ func strSliceField(fields map[string]any, key string) []string {
505
673
  out, _ := fields[key].([]string)
506
674
  return out
507
675
  }
676
+
677
+ var (
678
+ _ task.System = (*system)(nil)
679
+ _ task.LifecycleDefaults = (*system)(nil)
680
+ )