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
@@ -0,0 +1,840 @@
1
+ // Package beads is the Beads task-system adapter. Beads owns its storage and
2
+ // workspace semantics; this package keeps those details behind the task.System
3
+ // boundary and talks to the bd CLI through bdcli.
4
+ package beads
5
+
6
+ import (
7
+ "context"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "os"
12
+ "path/filepath"
13
+ "regexp"
14
+ "strings"
15
+ "sync"
16
+
17
+ "github.com/rajpopat27/relay-flow/internal/config"
18
+ "github.com/rajpopat27/relay-flow/internal/retry"
19
+ "github.com/rajpopat27/relay-flow/internal/task"
20
+ "github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
21
+ )
22
+
23
+ // Config is the Beads-owned task configuration shared by root, repository,
24
+ // workflow, and node scopes.
25
+ type Config struct {
26
+ BeadsDir string `yaml:"beadsDir"`
27
+ Assignee string `yaml:"assignee,omitempty"`
28
+ Filters Filters `yaml:"filters,omitempty"`
29
+ Transition TransitionTo `yaml:"transitionTo,omitempty"`
30
+ Templates Templates `yaml:"templates,omitempty"`
31
+ }
32
+
33
+ // Filters are the structured, locally evaluated Beads ticket filters.
34
+ type Filters struct {
35
+ ParentStatuses []string `yaml:"parentStatuses,omitempty"`
36
+ IssueTypes []string `yaml:"issueTypes,omitempty"`
37
+ Labels []string `yaml:"labels,omitempty"`
38
+ Assignees []string `yaml:"assignees,omitempty"`
39
+ }
40
+
41
+ // TransitionTo carries shared parent and mailbox status transitions.
42
+ type TransitionTo struct {
43
+ ParentStatus string `yaml:"parentStatus,omitempty"`
44
+ TaskStatus string `yaml:"taskStatus,omitempty"`
45
+ }
46
+
47
+ // Templates are Beads-owned mailbox and comment templates.
48
+ type Templates struct {
49
+ MailboxDescription string `yaml:"mailboxDescription"`
50
+ SummaryComment string `yaml:"summaryComment"`
51
+ FeedbackComment string `yaml:"feedbackComment"`
52
+ }
53
+
54
+ const (
55
+ defaultMailboxDescription = `Parent ticket: {{ticket}}
56
+ Workflow: {{workflow}}
57
+ Node: {{node}}
58
+ Node type: {{nodeType}}
59
+ Agent: {{agent}}
60
+ Mailbox: {{mailbox}}
61
+
62
+ Node work:
63
+ {{nodeDescription}}
64
+
65
+ Read this mailbox's comments for feedback from previous nodes.`
66
+ defaultSummaryComment = `Summary for {{node}}
67
+
68
+ {{summaryReport}}`
69
+ defaultFeedbackComment = `Feedback from {{sourceNode}} to {{targetNode}} mailbox {{mailbox}}
70
+
71
+ {{feedbackReport}}`
72
+ )
73
+
74
+ // DefaultConfig supplies only Beads task-system text defaults. A fresh map is
75
+ // returned for every call so callers can merge or modify it independently.
76
+ func DefaultConfig() config.RawValues {
77
+ return config.RawValues{"templates": map[string]any{
78
+ "mailboxDescription": defaultMailboxDescription,
79
+ "summaryComment": defaultSummaryComment,
80
+ "feedbackComment": defaultFeedbackComment,
81
+ }}
82
+ }
83
+
84
+ func init() {
85
+ task.Register("beads", task.Factory{
86
+ RequiredRepoKeys: func() []string { return []string{"beadsDir"} },
87
+ TaskScopeKey: beadsTaskScopeKey,
88
+ Auth: beadsAuth,
89
+ DefaultConfig: DefaultConfig,
90
+ ValidateTextConfig: validateTextConfig,
91
+ New: newSystem,
92
+ })
93
+ }
94
+
95
+ // Beads status names relay-flow itself applies. Every other Beads status is
96
+ // treated as a human-owned state that blocks a transition.
97
+ const (
98
+ statusOpen = "open"
99
+ statusInProgress = "in_progress"
100
+ statusClosed = "closed"
101
+ )
102
+
103
+ // system is one repo-bound Beads task system. Its CLI client serializes
104
+ // commands for the selected workspace, making the system safe for concurrent
105
+ // Repo Poller and durable-activity use.
106
+ type system struct {
107
+ cli bdcli.Client
108
+ mailboxMu sync.Mutex
109
+ base config.RawValues
110
+ effective Config
111
+ }
112
+
113
+ // beadsTaskScopeKey returns the canonical physical Beads workspace. The
114
+ // workspace must be supplied by repoConfig; a root-level value never satisfies
115
+ // the required repo-scoped key.
116
+ func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error) {
117
+ var root Config
118
+ if err := config.DecodeStrict(rootConfig, &root); err != nil {
119
+ return "", fmt.Errorf("root task config: %w", err)
120
+ }
121
+ var repo Config
122
+ if err := config.DecodeStrict(repoConfig, &repo); err != nil {
123
+ return "", fmt.Errorf("repo task config: %w", err)
124
+ }
125
+ if strings.TrimSpace(repo.BeadsDir) == "" {
126
+ return "", errors.New("beads task scope requires repo beadsDir")
127
+ }
128
+ return canonicalBeadsDir(repo.BeadsDir)
129
+ }
130
+
131
+ func canonicalBeadsDir(value string) (string, error) {
132
+ trimmed := strings.TrimSpace(value)
133
+ if trimmed == "" {
134
+ return "", errors.New("beadsDir must not be empty")
135
+ }
136
+ abs, err := filepath.Abs(trimmed)
137
+ if err != nil {
138
+ return "", fmt.Errorf("resolve beadsDir %q: %w", trimmed, err)
139
+ }
140
+ info, err := os.Stat(abs)
141
+ if err != nil {
142
+ return "", fmt.Errorf("stat beadsDir %q: %w", trimmed, err)
143
+ }
144
+ if !info.IsDir() {
145
+ return "", fmt.Errorf("beadsDir %q is not a directory", trimmed)
146
+ }
147
+ resolved, err := filepath.EvalSymlinks(abs)
148
+ if err != nil {
149
+ return "", fmt.Errorf("canonicalize beadsDir %q: %w", trimmed, err)
150
+ }
151
+ return filepath.Clean(resolved), nil
152
+ }
153
+
154
+ // newSystem constructs and probes a repo-bound Beads task system. It does not
155
+ // initialize a workspace or start any Beads/Dolt server.
156
+ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
157
+ if strings.TrimSpace(spec.Name) == "" {
158
+ return nil, errors.New("beads: repo name is required")
159
+ }
160
+ // Validate the repo-scoped key before merging with root values. This keeps a
161
+ // root beadsDir from silently satisfying repository registration.
162
+ beadsDir, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
163
+ if err != nil {
164
+ return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
165
+ }
166
+ merged := config.Merge(DefaultConfig(), spec.RootConfig, spec.RepoConfig)
167
+ cfg, err := decodeConfig(merged)
168
+ if err != nil {
169
+ return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
170
+ }
171
+ if err := validateTextConfig(merged); err != nil {
172
+ return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
173
+ }
174
+ cli := bdcli.New(spec.Path, beadsDir)
175
+ if err := cli.Probe(ctx); err != nil {
176
+ return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
177
+ }
178
+ return &system{cli: cli, base: merged, effective: cfg}, nil
179
+ }
180
+
181
+ func decodeConfig(raw config.RawValues) (Config, error) {
182
+ var cfg Config
183
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
184
+ return Config{}, err
185
+ }
186
+ return cfg, nil
187
+ }
188
+
189
+ // validateTextConfig performs strict Beads config and template validation.
190
+ func validateTextConfig(raw config.RawValues) error {
191
+ merged := config.Merge(DefaultConfig(), raw)
192
+ cfg, err := decodeConfig(merged)
193
+ if err != nil {
194
+ return err
195
+ }
196
+ return validateTemplates(cfg.Templates)
197
+ }
198
+
199
+ var (
200
+ textVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
201
+ knownTextVars = map[string]bool{
202
+ "runID": true, "ticket": true, "workflow": true,
203
+ "repo": true, "node": true, "nodeType": true, "agent": true,
204
+ "nodeDescription": true, "nextSteps": true, "successRoutes": true,
205
+ "failureRoutes": true, "mailbox": true, "sourceNode": true,
206
+ "targetNode": true, "summaryReport": true, "feedbackReport": true,
207
+ }
208
+ )
209
+
210
+ func validateTemplates(templates Templates) error {
211
+ for name, tmpl := range map[string]string{
212
+ "mailboxDescription": templates.MailboxDescription,
213
+ "summaryComment": templates.SummaryComment,
214
+ "feedbackComment": templates.FeedbackComment,
215
+ } {
216
+ for _, match := range textVarPattern.FindAllStringSubmatch(tmpl, -1) {
217
+ if !knownTextVars[match[1]] {
218
+ return fmt.Errorf("%s: unknown template variable {{%s}}", name, match[1])
219
+ }
220
+ }
221
+ }
222
+ if !strings.Contains(templates.SummaryComment, "{{summaryReport}}") {
223
+ return errors.New("summaryComment must contain {{summaryReport}}")
224
+ }
225
+ if !strings.Contains(templates.FeedbackComment, "{{feedbackReport}}") {
226
+ return errors.New("feedbackComment must contain {{feedbackReport}}")
227
+ }
228
+ return nil
229
+ }
230
+
231
+ // beadsAuth intentionally has no credential flow. Beads authentication and
232
+ // server credentials belong to the bd workspace; relay-flow must not create a
233
+ // credentials file. Non-empty arguments are rejected rather than ignored.
234
+ func beadsAuth(_ context.Context, args []string, _ io.Reader) error {
235
+ if len(args) != 0 {
236
+ return errors.New("beads task auth does not accept arguments")
237
+ }
238
+ return nil
239
+ }
240
+
241
+ // Poll reads ready and relay-owned active issues once each, merges overlapping
242
+ // results by issue ID, and returns only top-level issues. The CLI's
243
+ // --no-parent flag is an optimization rather than the correctness boundary:
244
+ // every returned issue is checked again before normalization.
245
+ func (s *system) Poll(ctx context.Context) ([]task.Ticket, error) {
246
+ ready, err := s.cli.ListReady(ctx)
247
+ if err != nil {
248
+ return nil, err
249
+ }
250
+ claimed, err := s.cli.ListClaimed(ctx)
251
+ if err != nil {
252
+ return nil, err
253
+ }
254
+
255
+ issues := make(map[string]bdcli.Issue, len(ready)+len(claimed))
256
+ order := make([]string, 0, len(ready)+len(claimed))
257
+ for _, issue := range ready {
258
+ if _, exists := issues[issue.ID]; !exists {
259
+ order = append(order, issue.ID)
260
+ }
261
+ issues[issue.ID] = issue
262
+ }
263
+ for _, issue := range claimed {
264
+ if _, exists := issues[issue.ID]; !exists {
265
+ order = append(order, issue.ID)
266
+ }
267
+ // Claimed results are read after ready results and therefore replace an
268
+ // overlapping ready copy with its current labels/status.
269
+ issues[issue.ID] = issue
270
+ }
271
+
272
+ tickets := make([]task.Ticket, 0, len(order))
273
+ for _, issueID := range order {
274
+ issue := issues[issueID]
275
+ if strings.TrimSpace(issue.Parent) != "" {
276
+ continue
277
+ }
278
+ tickets = append(tickets, issueToTicket(issue))
279
+ }
280
+ return tickets, nil
281
+ }
282
+
283
+ // issueToTicket converts the small Beads issue shape into the core ticket
284
+ // contract. Beads issue IDs are stable identities for both Ticket.ID and
285
+ // Ticket.Key; workflow labels are retained separately for routing and in the
286
+ // normalized fields for adapter-owned filter matching.
287
+ func issueToTicket(issue bdcli.Issue) task.Ticket {
288
+ return task.Ticket{
289
+ ID: issue.ID,
290
+ Key: issue.ID,
291
+ Title: issue.Title,
292
+ WorkflowClaims: extractWorkflowClaims(issue.Labels),
293
+ Fields: normalizeFields(issue),
294
+ }
295
+ }
296
+
297
+ func normalizeFields(issue bdcli.Issue) map[string]any {
298
+ return map[string]any{
299
+ "status": issue.Status,
300
+ "issueType": issue.IssueType,
301
+ "assignee": issue.Assignee,
302
+ "priority": issue.Priority,
303
+ "description": issue.Description,
304
+ "labels": append([]string(nil), issue.Labels...),
305
+ }
306
+ }
307
+
308
+ func extractWorkflowClaims(labels []string) []string {
309
+ claims := make([]string, 0)
310
+ for _, label := range labels {
311
+ if strings.HasPrefix(label, "wf:") && len(label) > len("wf:") {
312
+ claims = append(claims, label)
313
+ }
314
+ }
315
+ return claims
316
+ }
317
+
318
+ func claimLabel(workflow string) string {
319
+ return "wf:" + workflow
320
+ }
321
+
322
+ // CompileFilter compiles Beads-owned structured filters into a local matcher.
323
+ // No Beads query language is accepted or sent to the CLI.
324
+ func (s *system) CompileFilter(workflowTaskConfig config.RawValues) (func(task.Ticket) bool, error) {
325
+ merged := config.Merge(s.base, workflowTaskConfig)
326
+ cfg, err := decodeConfig(merged)
327
+ if err != nil {
328
+ return nil, err
329
+ }
330
+ f := cfg.Filters
331
+ if !hasAssigneeFilter(merged) && cfg.Assignee != "" {
332
+ f.Assignees = []string{cfg.Assignee}
333
+ }
334
+ return func(ticket task.Ticket) bool {
335
+ if len(f.ParentStatuses) > 0 && !containsExact(f.ParentStatuses, stringField(ticket.Fields, "status")) {
336
+ return false
337
+ }
338
+ if len(f.IssueTypes) > 0 && !containsExact(f.IssueTypes, stringField(ticket.Fields, "issueType")) {
339
+ return false
340
+ }
341
+ if len(f.Labels) > 0 {
342
+ labels := stringSliceField(ticket.Fields, "labels")
343
+ for _, required := range f.Labels {
344
+ if !containsExact(labels, required) {
345
+ return false
346
+ }
347
+ }
348
+ }
349
+ if len(f.Assignees) > 0 && !containsFold(f.Assignees, stringField(ticket.Fields, "assignee")) {
350
+ return false
351
+ }
352
+ return true
353
+ }, nil
354
+ }
355
+
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
+ func containsExact(values []string, want string) bool {
370
+ for _, value := range values {
371
+ if value == want {
372
+ return true
373
+ }
374
+ }
375
+ return false
376
+ }
377
+
378
+ func containsFold(values []string, want string) bool {
379
+ for _, value := range values {
380
+ if strings.EqualFold(value, want) {
381
+ return true
382
+ }
383
+ }
384
+ return false
385
+ }
386
+
387
+ func stringField(fields map[string]any, key string) string {
388
+ value, _ := fields[key].(string)
389
+ return value
390
+ }
391
+
392
+ func stringSliceField(fields map[string]any, key string) []string {
393
+ value, _ := fields[key].([]string)
394
+ return value
395
+ }
396
+
397
+ // Claim adds the permanent workflow label to the parent issue. Routing has
398
+ // already resolved the workflow before this method runs, so no Beads ready or
399
+ // claim command is used and status/assignee are left untouched.
400
+ func (s *system) Claim(ctx context.Context, ticket task.TicketRef, workflow string) error {
401
+ if strings.TrimSpace(ticket.Key) == "" {
402
+ return errors.New("beads: ticket key is required to claim")
403
+ }
404
+ if strings.TrimSpace(workflow) == "" {
405
+ return errors.New("beads: workflow is required to claim")
406
+ }
407
+ return s.cli.Update(ctx, ticket.Key, bdcli.UpdateInput{
408
+ AddLabels: []string{claimLabel(workflow)},
409
+ })
410
+ }
411
+
412
+ // ValidateConfig strictly validates the merged workflow and node task
413
+ // configuration. It is intentionally local: no Beads command is needed to
414
+ // validate structured filters, statuses, or templates.
415
+ func (s *system) ValidateConfig(_ context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error {
416
+ workflowRaw := config.Merge(s.base, workflowTaskConfig)
417
+ workflowCfg, err := decodeConfig(workflowRaw)
418
+ if err != nil {
419
+ return fmt.Errorf("workflow taskConfig: %w", err)
420
+ }
421
+ if err := validateBeadsConfig(workflowCfg); err != nil {
422
+ return fmt.Errorf("workflow taskConfig: %w", err)
423
+ }
424
+ if _, ok := workflowTaskConfig["templates"]; ok {
425
+ return errors.New("template overrides are unsupported at workflow/node scope")
426
+ }
427
+ for node, raw := range nodeTaskConfigs {
428
+ if _, ok := raw["templates"]; ok {
429
+ return fmt.Errorf("node %q taskConfig: template overrides are unsupported at workflow/node scope", node)
430
+ }
431
+ cfg, err := decodeConfig(config.Merge(s.base, workflowTaskConfig, raw))
432
+ if err != nil {
433
+ return fmt.Errorf("node %q taskConfig: %w", node, err)
434
+ }
435
+ if err := validateBeadsConfig(cfg); err != nil {
436
+ return fmt.Errorf("node %q taskConfig: %w", node, err)
437
+ }
438
+ }
439
+ return nil
440
+ }
441
+
442
+ func validateBeadsConfig(cfg Config) error {
443
+ if err := validateTemplates(cfg.Templates); err != nil {
444
+ return fmt.Errorf("templates: %w", err)
445
+ }
446
+ if err := validateFilterValues(cfg.Filters); err != nil {
447
+ return fmt.Errorf("filters: %w", err)
448
+ }
449
+ if err := validateStatusValue("parentStatus", cfg.Transition.ParentStatus); err != nil {
450
+ return err
451
+ }
452
+ return validateStatusValue("taskStatus", cfg.Transition.TaskStatus)
453
+ }
454
+
455
+ func validateFilterValues(filters Filters) error {
456
+ for name, values := range map[string][]string{
457
+ "parentStatuses": filters.ParentStatuses,
458
+ "issueTypes": filters.IssueTypes,
459
+ "labels": filters.Labels,
460
+ "assignees": filters.Assignees,
461
+ } {
462
+ for i, value := range values {
463
+ if strings.TrimSpace(value) == "" {
464
+ return fmt.Errorf("%s[%d] must not be empty", name, i)
465
+ }
466
+ }
467
+ }
468
+ return nil
469
+ }
470
+
471
+ func validateStatusValue(name, value string) error {
472
+ if strings.TrimSpace(value) == "" {
473
+ return nil
474
+ }
475
+ // These are the status names used by the bd contract. Keep this validation
476
+ // local and reject typos before durable workflows are submitted.
477
+ switch value {
478
+ case "open", "in_progress", "blocked", "deferred", "hooked", "closed":
479
+ return nil
480
+ default:
481
+ return fmt.Errorf("transitionTo.%s %q is not a supported Beads status", name, value)
482
+ }
483
+ }
484
+
485
+ // RenderText expands the adapter-owned task-system templates using the same
486
+ // simple replacement rules as the other task adapters.
487
+ func (s *system) RenderText(kind task.TextKind, data task.TextData) (string, error) {
488
+ var template string
489
+ switch kind {
490
+ case task.TextMailboxDescription:
491
+ template = s.effective.Templates.MailboxDescription
492
+ case task.TextSummaryComment:
493
+ template = s.effective.Templates.SummaryComment
494
+ case task.TextFeedbackComment:
495
+ template = s.effective.Templates.FeedbackComment
496
+ default:
497
+ return "", fmt.Errorf("beads: unknown task text kind %q", kind)
498
+ }
499
+ values := map[string]string{
500
+ "runID": data.RunID, "ticket": data.Ticket, "workflow": data.Workflow,
501
+ "repo": data.Repo, "node": data.Node, "nodeType": data.NodeType,
502
+ "agent": data.Agent, "nodeDescription": data.NodeDescription,
503
+ "nextSteps": data.NextSteps, "successRoutes": data.SuccessRoutes,
504
+ "failureRoutes": data.FailureRoutes, "mailbox": data.Mailbox,
505
+ "sourceNode": data.SourceNode, "targetNode": data.TargetNode,
506
+ "summaryReport": data.SummaryReport, "feedbackReport": data.FeedbackReport,
507
+ }
508
+ return textVarPattern.ReplaceAllStringFunc(template, func(match string) string {
509
+ parts := textVarPattern.FindStringSubmatch(match)
510
+ if len(parts) != 2 {
511
+ return match
512
+ }
513
+ return values[parts[1]]
514
+ }), nil
515
+ }
516
+
517
+ // EnsureMailboxes finds reusable child issues by their stable title, updates
518
+ // existing descriptions/labels, creates only missing children, and returns a
519
+ // complete node-to-mailbox map.
520
+ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, workflow string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
521
+ s.mailboxMu.Lock()
522
+ defer s.mailboxMu.Unlock()
523
+
524
+ parentID := parent.Key
525
+ if strings.TrimSpace(parentID) == "" {
526
+ parentID = parent.ID
527
+ }
528
+ if strings.TrimSpace(parentID) == "" {
529
+ return nil, errors.New("beads: parent key is required to ensure mailboxes")
530
+ }
531
+ if strings.TrimSpace(workflow) == "" {
532
+ return nil, errors.New("beads: workflow is required to ensure mailboxes")
533
+ }
534
+ children, err := s.cli.ListChildren(ctx, parentID)
535
+ if err != nil {
536
+ return nil, fmt.Errorf("list children of %q: %w", parentID, err)
537
+ }
538
+
539
+ seenSpecs := make(map[string]struct{}, len(specs))
540
+ for _, spec := range specs {
541
+ if _, exists := seenSpecs[spec.Node]; exists {
542
+ return nil, fmt.Errorf("duplicate mailbox node %q", spec.Node)
543
+ }
544
+ seenSpecs[spec.Node] = struct{}{}
545
+ }
546
+
547
+ type requestedMailbox struct {
548
+ spec task.MailboxSpec
549
+ title string
550
+ issue bdcli.Issue
551
+ }
552
+ requested := make([]requestedMailbox, 0, len(specs))
553
+ for _, spec := range specs {
554
+ title := mailboxTitle(parentID, spec.Node)
555
+ issue, err := findMailbox(children, title)
556
+ if err == nil {
557
+ requested = append(requested, requestedMailbox{spec: spec, title: title, issue: issue})
558
+ continue
559
+ }
560
+ if !errors.Is(err, errMailboxNotFound) {
561
+ return nil, err
562
+ }
563
+ requested = append(requested, requestedMailbox{spec: spec, title: title})
564
+ }
565
+
566
+ out := make(map[string]task.Mailbox, len(specs))
567
+ missing := make([]task.MailboxSpec, 0, len(specs))
568
+ for _, mailbox := range requested {
569
+ if mailbox.issue.ID != "" {
570
+ description := mailbox.spec.Description
571
+ if err := s.cli.Update(ctx, mailbox.issue.ID, bdcli.UpdateInput{
572
+ Description: &description,
573
+ AddLabels: []string{claimLabel(workflow)},
574
+ }); err != nil {
575
+ return nil, fmt.Errorf("reconcile mailbox %q: %w", mailbox.issue.ID, err)
576
+ }
577
+ out[mailbox.spec.Node] = issueToMailbox(mailbox.issue, mailbox.spec.Node)
578
+ continue
579
+ }
580
+ missing = append(missing, task.MailboxSpec{
581
+ Node: mailbox.spec.Node,
582
+ Title: mailbox.title,
583
+ Description: mailbox.spec.Description,
584
+ TaskConfig: mailbox.spec.TaskConfig,
585
+ TextData: mailbox.spec.TextData,
586
+ })
587
+ }
588
+
589
+ for _, spec := range missing {
590
+ issue, err := s.cli.CreateChild(ctx, parentID, spec.Title, spec.Description, claimLabel(workflow))
591
+ if err != nil {
592
+ return nil, fmt.Errorf("create mailbox %q: %w", spec.Title, err)
593
+ }
594
+ if issue.ID == "" {
595
+ return nil, fmt.Errorf("create mailbox %q returned no issue ID", spec.Title)
596
+ }
597
+ out[spec.Node] = issueToMailbox(issue, spec.Node)
598
+ }
599
+ return out, nil
600
+ }
601
+
602
+ func mailboxTitle(parentID, node string) string {
603
+ return parentID + ":" + node
604
+ }
605
+
606
+ var errMailboxNotFound = errors.New("mailbox not found")
607
+
608
+ func findMailbox(children []bdcli.Issue, title string) (bdcli.Issue, error) {
609
+ var found bdcli.Issue
610
+ foundMatch := false
611
+ for _, child := range children {
612
+ if child.Title != title {
613
+ continue
614
+ }
615
+ if foundMatch {
616
+ return bdcli.Issue{}, fmt.Errorf("duplicate mailbox title %q", title)
617
+ }
618
+ found = child
619
+ foundMatch = true
620
+ }
621
+ if !foundMatch {
622
+ return bdcli.Issue{}, fmt.Errorf("%w: %q", errMailboxNotFound, title)
623
+ }
624
+ if found.ID == "" {
625
+ return bdcli.Issue{}, fmt.Errorf("mailbox %q has no issue ID", title)
626
+ }
627
+ return found, nil
628
+ }
629
+
630
+ func issueToMailbox(issue bdcli.Issue, node string) task.Mailbox {
631
+ return task.Mailbox{ID: issue.ID, Key: issue.ID, Node: node}
632
+ }
633
+
634
+ // ApplyTaskConfig applies the shared transitionTo values and the optional
635
+ // assignee to the operation's target. Inherited root/repo values reach this
636
+ // method through the lifecycle defaults, so the effective configuration is
637
+ // already merged in precedence order by the caller.
638
+ func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskConfig config.RawValues) error {
639
+ cfg, err := decodeConfig(taskConfig)
640
+ if err != nil {
641
+ return err
642
+ }
643
+ if target.Mailbox != nil {
644
+ // The mailbox carries the node's own status and assignee; the parent is
645
+ // touched from a mailbox target only when parentStatus is configured.
646
+ if err := s.reconcileIssue(ctx, target.Mailbox.Key,
647
+ mailboxSources(cfg.Transition.TaskStatus), cfg.Transition.TaskStatus, cfg.Assignee); err != nil {
648
+ return err
649
+ }
650
+ }
651
+ if cfg.Transition.ParentStatus == "" {
652
+ return nil
653
+ }
654
+ return s.reconcileIssue(ctx, target.Parent.Key,
655
+ parentSources(cfg.Transition.ParentStatus), cfg.Transition.ParentStatus, "")
656
+ }
657
+
658
+ // mailboxSources lists the states a mailbox may hold before relay-flow moves
659
+ // it to target. A mailbox is entered from its initial open state or from the
660
+ // closed state left by CompleteMailbox on a workflow revisit, and is completed
661
+ // only from in_progress. Every other state is human-owned.
662
+ func mailboxSources(target string) []string {
663
+ switch target {
664
+ case statusInProgress:
665
+ return []string{statusOpen, statusClosed}
666
+ case statusClosed:
667
+ return []string{statusInProgress}
668
+ default:
669
+ return []string{statusOpen}
670
+ }
671
+ }
672
+
673
+ // parentSources lists the states a parent may hold before relay-flow moves it
674
+ // to target. relay-flow only ever drives a parent through open and
675
+ // in_progress, so a parent parked in any other state (for example a human
676
+ // setting blocked or deferred) blocks the transition instead of being
677
+ // overwritten, matching the mailbox rule.
678
+ func parentSources(target string) []string {
679
+ if target == statusClosed {
680
+ return []string{statusOpen, statusInProgress}
681
+ }
682
+ return []string{statusOpen}
683
+ }
684
+
685
+ // reconcileIssue reads the issue once and applies at most one bd update. A
686
+ // status already at target is an idempotent success, an unexpected status is a
687
+ // retryable conflict, and an expected status receives an unconditional update.
688
+ // The small race between this read and write is an accepted last-writer-wins
689
+ // behavior for this adapter; do not add --if-status or a fallback path.
690
+ func (s *system) reconcileIssue(ctx context.Context, issueID string, expected []string, target, assignee string) error {
691
+ if target == "" && assignee == "" {
692
+ return nil
693
+ }
694
+ if strings.TrimSpace(issueID) == "" {
695
+ return errors.New("beads: issue key is required for status reconciliation")
696
+ }
697
+ issue, err := s.cli.Show(ctx, issueID)
698
+ if err != nil {
699
+ return err
700
+ }
701
+ var input bdcli.UpdateInput
702
+ if target != "" && issue.Status != target {
703
+ if !containsExact(expected, issue.Status) {
704
+ return retry.ConflictError(fmt.Errorf(
705
+ "issue %q has status %q; expected one of [%s] before changing to %q",
706
+ issueID, issue.Status, strings.Join(expected, " "), target))
707
+ }
708
+ input.Status = target
709
+ }
710
+ if assignee != "" && issue.Assignee != assignee {
711
+ input.Assignee = assignee
712
+ }
713
+ if input.Status == "" && input.Assignee == "" {
714
+ return nil
715
+ }
716
+ return s.cli.Update(ctx, issueID, input)
717
+ }
718
+
719
+ // CompleteMailbox closes only the supplied mailbox. It performs the same
720
+ // read-before-write reconciliation as other Beads status operations: a closed
721
+ // mailbox is an idempotent success, an in-progress mailbox is unconditionally
722
+ // updated to closed, and an incompatible state is a retryable conflict.
723
+ func (s *system) CompleteMailbox(ctx context.Context, mailbox task.Mailbox) error {
724
+ if strings.TrimSpace(mailbox.Key) == "" {
725
+ return errors.New("beads: mailbox key is required to complete")
726
+ }
727
+ return s.reconcileIssue(ctx, mailbox.Key, mailboxSources(statusClosed), statusClosed, "")
728
+ }
729
+
730
+ func targetIssueID(target task.Target) string {
731
+ if target.Mailbox != nil && strings.TrimSpace(target.Mailbox.Key) != "" {
732
+ return target.Mailbox.Key
733
+ }
734
+ return target.Parent.Key
735
+ }
736
+
737
+ // HasComment checks the selected issue's comments for a stable marker.
738
+ func (s *system) HasComment(ctx context.Context, target task.Target, marker string) (bool, error) {
739
+ issueID := targetIssueID(target)
740
+ if strings.TrimSpace(issueID) == "" {
741
+ return false, errors.New("beads: comment target key is required")
742
+ }
743
+ comments, err := s.cli.ListComments(ctx, issueID)
744
+ if err != nil {
745
+ return false, err
746
+ }
747
+ for _, comment := range comments {
748
+ if strings.Contains(comment.Text, marker) {
749
+ return true, nil
750
+ }
751
+ }
752
+ return false, nil
753
+ }
754
+
755
+ // Comment checks for an existing marker before writing the marked body
756
+ // through bdcli's stdin-safe comment operation.
757
+ func (s *system) Comment(ctx context.Context, target task.Target, body, marker string) error {
758
+ issueID := targetIssueID(target)
759
+ if strings.TrimSpace(issueID) == "" {
760
+ return errors.New("beads: comment target key is required")
761
+ }
762
+ exists, err := s.HasComment(ctx, target, marker)
763
+ if err != nil {
764
+ return err
765
+ }
766
+ if exists {
767
+ return nil
768
+ }
769
+ return s.cli.AddComment(ctx, issueID, body+"\n\n<!-- "+marker+" -->")
770
+ }
771
+
772
+ // Lifecycle defaults carry both the built-in Beads lifecycle behavior and the
773
+ // inherited root/repo values for this operation. Returning them together is
774
+ // what makes the documented precedence hold at runtime: the caller merges
775
+ // these values underneath the workflow/node configuration, producing
776
+ // built-in default < root < repo < workflow < node.
777
+
778
+ // StartDefaults moves the parent to in_progress when a run starts, matching
779
+ // the Jira adapter. A claimed parent stays visible to the claimed-parent poll
780
+ // because that query includes in_progress.
781
+ func (s *system) StartDefaults() config.RawValues {
782
+ return s.lifecycleDefaults(transitionDefault("parentStatus", statusInProgress))
783
+ }
784
+
785
+ // WorkDefaults starts a work mailbox in progress and leaves the parent alone.
786
+ func (s *system) WorkDefaults() config.RawValues {
787
+ return s.lifecycleDefaults(transitionDefault("taskStatus", statusInProgress))
788
+ }
789
+
790
+ // EndDefaults closes the parent after workflow completion.
791
+ func (s *system) EndDefaults() config.RawValues {
792
+ return s.lifecycleDefaults(transitionDefault("parentStatus", statusClosed))
793
+ }
794
+
795
+ func transitionDefault(key, value string) config.RawValues {
796
+ return config.RawValues{"transitionTo": map[string]any{key: value}}
797
+ }
798
+
799
+ // lifecycleDefaults layers the inherited root/repo operation values over the
800
+ // built-in lifecycle default. Only the keys ApplyTaskConfig consumes are
801
+ // carried, so unrelated configuration never enters durable activity inputs.
802
+ func (s *system) lifecycleDefaults(builtin config.RawValues) config.RawValues {
803
+ inherited := config.RawValues{}
804
+ for _, key := range []string{"transitionTo", "assignee"} {
805
+ if value, ok := s.base[key]; ok {
806
+ inherited[key] = value
807
+ }
808
+ }
809
+ return config.Merge(builtin, inherited)
810
+ }
811
+
812
+ // ResetForRecovery reopens the parent and every known mailbox, clearing any
813
+ // deferred state while preserving comments, labels, descriptions, history,
814
+ // and issues themselves.
815
+ func (s *system) ResetForRecovery(ctx context.Context, parent task.TicketRef, mailboxes []task.Mailbox, _ config.RawValues) error {
816
+ parentID := parent.Key
817
+ if strings.TrimSpace(parentID) == "" {
818
+ parentID = parent.ID
819
+ }
820
+ if strings.TrimSpace(parentID) == "" {
821
+ return errors.New("beads: parent key is required for recovery reset")
822
+ }
823
+ if err := s.cli.Update(ctx, parentID, bdcli.UpdateInput{Status: statusOpen, ClearDefer: true}); err != nil {
824
+ return fmt.Errorf("reset parent %q: %w", parentID, err)
825
+ }
826
+ for _, mailbox := range mailboxes {
827
+ if strings.TrimSpace(mailbox.Key) == "" {
828
+ return errors.New("beads: mailbox key is required for recovery reset")
829
+ }
830
+ if err := s.cli.Update(ctx, mailbox.Key, bdcli.UpdateInput{Status: statusOpen, ClearDefer: true}); err != nil {
831
+ return fmt.Errorf("reset mailbox %q: %w", mailbox.Key, err)
832
+ }
833
+ }
834
+ return nil
835
+ }
836
+
837
+ var (
838
+ _ task.System = (*system)(nil)
839
+ _ task.LifecycleDefaults = (*system)(nil)
840
+ )