relay-flow 0.3.1-alpha → 0.3.3-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,13 +24,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
24
24
  **OpenCode**
25
25
 
26
26
  ```sh
27
- opencode plugin relay-flow-plugin@0.3.1-alpha
27
+ opencode plugin relay-flow-plugin@0.3.3-alpha
28
28
  ```
29
29
 
30
30
  **Pi**
31
31
 
32
32
  ```sh
33
- pi install npm:relay-flow-plugin@0.3.1-alpha
33
+ pi install npm:relay-flow-plugin@0.3.3-alpha
34
34
  ```
35
35
 
36
36
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -160,7 +160,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
160
160
  ```json
161
161
  {
162
162
  "$schema": "https://opencode.ai/config.json",
163
- "plugin": ["relay-flow-plugin@0.3.1-alpha"]
163
+ "plugin": ["relay-flow-plugin@0.3.3-alpha"]
164
164
  }
165
165
  ```
166
166
 
@@ -169,7 +169,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
169
169
  ```json
170
170
  {
171
171
  "$schema": "https://opencode.ai/tui.json",
172
- "plugin": ["relay-flow-plugin@0.3.1-alpha"]
172
+ "plugin": ["relay-flow-plugin@0.3.3-alpha"]
173
173
  }
174
174
  ```
175
175
 
@@ -185,7 +185,7 @@ Pi plugin: install the same published package manually in Pi's global package
185
185
  settings before starting a Pi harness session:
186
186
 
187
187
  ```sh
188
- pi install npm:relay-flow-plugin@0.3.1-alpha
188
+ pi install npm:relay-flow-plugin@0.3.3-alpha
189
189
  ```
190
190
 
191
191
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -379,7 +379,7 @@ Jira and Beads use the same conceptual `Task` issue type, but each adapter
379
379
  keeps its provider-native spelling: Jira uses `Task` and Beads uses `task`.
380
380
  Filter values are exact and are not translated between providers.
381
381
 
382
- The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`, a work-node mailbox to `in_progress`, and the parent to `closed` at `end` — the same shape as Jira, with Beads-native values. Relay-flow creates one Repo Poller per registered repo, not one poller per workflow. Each poll reads ready top-level parents and relay-owned active parents, deduplicates them, and never routes mailbox children. Claims are permanent `wf:<workflow>` labels.
382
+ The supported Beads status names are `open`, `in_progress`, `blocked`, `deferred`, `hooked`, and `closed`. The claimed-parent poll uses the canonical active set `open,in_progress,blocked,deferred`; it intentionally does not substitute `hooked` for `deferred`. Omitted lifecycle settings move the parent to `in_progress` at `start`, both the parent and current work-node mailbox to `in_progress` when work begins, and the parent to `closed` at `end` — the same shape as Jira, with Beads-native values. Relay-flow creates one Repo Poller per registered repo, not one poller per workflow. Each poll reads ready top-level parents and relay-owned active parents, deduplicates them, and never routes mailbox children. Claims are permanent `wf:<workflow>` labels.
383
383
 
384
384
  Beads does not need relay-flow credentials or a Beads-specific poller. In server mode, leave Dolt and Beads server setup running outside relay-flow; each registered code repository still supplies its `beadsDir`, and repositories that share that workspace are isolated by their derived `repo:` labels.
385
385
 
@@ -494,18 +494,18 @@ Rules enforced at submit:
494
494
 
495
495
  ### Jira transition defaults
496
496
 
497
- Omitted transitions default to:
497
+ Omitted transitions use the repository-configured Jira lifecycle statuses:
498
498
 
499
- - `start`: parent → `In Progress`
500
- - work node: mailbox → `In Progress` (parent unchanged)
501
- - `end`: parent → `Done`
499
+ - `start`: parent → `statusDefaults.start`
500
+ - work node: parent and current mailbox → `statusDefaults.work` before the node runtime launches
501
+ - `end`: parent → `statusDefaults.end`
502
502
 
503
503
  ### Beads transition defaults
504
504
 
505
505
  Beads follows the same lifecycle shape with Beads-native values:
506
506
 
507
507
  - `start`: parent → `in_progress`
508
- - work node: mailbox → `in_progress` (parent unchanged)
508
+ - work node: parent and mailbox → `in_progress`
509
509
  - `end`: parent → `closed`
510
510
 
511
511
  A parent moved to `in_progress` stays visible to the claimed-parent poll, which reads `open,in_progress,blocked,deferred`. Entering a node reuses that node's mailbox: a fresh mailbox moves `open → in_progress` and a revisited one moves `closed → in_progress`. Beads reads the issue before every status write, so an already-applied status is a no-op and a status relay-flow did not set (for example a human marking an issue `blocked`) blocks the transition and retries instead of being overwritten.
@@ -853,8 +853,8 @@ func cmdServe(p paths.Paths, args []string) int {
853
853
  }
854
854
 
855
855
  var (
856
- backgroundServeStartupTimeout = 10 * time.Second
857
- backgroundServePollInterval = 50 * time.Millisecond
856
+ backgroundServeStartupTimeout = 5 * time.Minute
857
+ backgroundServePollInterval = 250 * time.Millisecond
858
858
  )
859
859
 
860
860
  func startBackgroundServe(p paths.Paths, recover, debug bool) error {
@@ -30,7 +30,7 @@ taskConfig:
30
30
 
31
31
  # transitionTo describes one lifecycle point, so configure it on a node.
32
32
  # Omitting it entirely already gives the Jira-equivalent lifecycle:
33
- # start parent -> in_progress, work mailbox -> in_progress, end parent -> closed.
33
+ # start parent -> in_progress, work parent and mailbox -> in_progress, end parent -> closed.
34
34
  # Values are Beads-native, not Jira values such as "In Progress" or "Done".
35
35
  nodes:
36
36
  start:
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.3.1-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.3-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.1-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.3-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -851,9 +851,12 @@ func (s *system) StartDefaults() config.RawValues {
851
851
  return s.lifecycleDefaults(transitionDefault("parentStatus", statusInProgress))
852
852
  }
853
853
 
854
- // WorkDefaults starts a work mailbox in progress and leaves the parent alone.
854
+ // WorkDefaults starts both the parent and current mailbox in progress.
855
855
  func (s *system) WorkDefaults() config.RawValues {
856
- return s.lifecycleDefaults(transitionDefault("taskStatus", statusInProgress))
856
+ return s.lifecycleDefaults(config.RawValues{"transitionTo": map[string]any{
857
+ "parentStatus": statusInProgress,
858
+ "taskStatus": statusInProgress,
859
+ }})
857
860
  }
858
861
 
859
862
  // EndDefaults closes the parent after workflow completion.
@@ -60,7 +60,7 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
60
60
  })
61
61
 
62
62
  t.Run("repo taskStatus reaches a work node", func(t *testing.T) {
63
- client := newStatusClient(map[string]string{"demo-parent.1": "open"})
63
+ client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
64
64
  sys := repoScopedSystem(client, config.RawValues{
65
65
  "transitionTo": map[string]any{"taskStatus": "blocked"},
66
66
  })
@@ -69,26 +69,30 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
69
69
  if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
70
70
  t.Fatal(err)
71
71
  }
72
- if len(client.updates) != 1 || client.updates[0].input.Status != "blocked" {
73
- t.Fatalf("updates = %+v, want the inherited repo value to beat the built-in default", client.updates)
72
+ if len(client.updates) != 2 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "blocked" ||
73
+ client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
74
+ t.Fatalf("updates = %+v, want inherited mailbox value followed by built-in parent work value", client.updates)
74
75
  }
75
76
  })
76
77
  }
77
78
 
78
79
  func TestLifecycleDefaultsCarryInheritedAssignee(t *testing.T) {
79
- client := newStatusClient(map[string]string{"demo-parent.1": "open"})
80
+ client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
80
81
  sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
81
82
  cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(), nil, nil)
82
83
 
83
84
  if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
84
85
  t.Fatal(err)
85
86
  }
86
- if len(client.updates) != 1 {
87
- t.Fatalf("updates = %+v, want one combined update", client.updates)
87
+ if len(client.updates) != 2 {
88
+ t.Fatalf("updates = %+v, want mailbox and parent updates", client.updates)
88
89
  }
89
- update := client.updates[0].input
90
- if update.Status != "in_progress" || update.Assignee != "repo-bot@example.com" {
91
- t.Fatalf("update = %+v, want the built-in status plus the inherited repo assignee", update)
90
+ mailboxUpdate := client.updates[0].input
91
+ if mailboxUpdate.Status != "in_progress" || mailboxUpdate.Assignee != "repo-bot@example.com" {
92
+ t.Fatalf("mailbox update = %+v, want the built-in status plus the inherited repo assignee", mailboxUpdate)
93
+ }
94
+ if client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
95
+ t.Fatalf("parent update = %+v, want built-in parent work status", client.updates[1])
92
96
  }
93
97
  }
94
98
 
@@ -113,7 +117,7 @@ func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
113
117
  },
114
118
  } {
115
119
  t.Run(tc.name, func(t *testing.T) {
116
- client := newStatusClient(map[string]string{"demo-parent.1": "open"})
120
+ client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
117
121
  sys := repoScopedSystem(client, config.RawValues{
118
122
  "transitionTo": map[string]any{"taskStatus": "blocked"},
119
123
  })
@@ -122,13 +126,41 @@ func TestLifecycleDefaultsPrecedenceDefaultRepoWorkflowNode(t *testing.T) {
122
126
  if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
123
127
  t.Fatal(err)
124
128
  }
125
- if len(client.updates) != 1 || client.updates[0].input.Status != tc.want {
126
- t.Fatalf("updates = %+v, want [%s]", client.updates, tc.want)
129
+ if len(client.updates) != 2 || client.updates[0].input.Status != tc.want ||
130
+ client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
131
+ t.Fatalf("updates = %+v, want mailbox %s followed by parent %s", client.updates, tc.want, statusInProgress)
127
132
  }
128
133
  })
129
134
  }
130
135
  }
131
136
 
137
+ func TestWorkDefaultsUseParentAndMailboxInProgress(t *testing.T) {
138
+ sys := repoScopedSystem(newStatusClient(nil), nil)
139
+ transition, ok := sys.WorkDefaults()["transitionTo"].(map[string]any)
140
+ if !ok {
141
+ t.Fatalf("WorkDefaults transitionTo = %#v, want map", sys.WorkDefaults()["transitionTo"])
142
+ }
143
+ if transition["parentStatus"] != statusInProgress || transition["taskStatus"] != statusInProgress {
144
+ t.Fatalf("WorkDefaults transitionTo = %#v, want both statuses %s", transition, statusInProgress)
145
+ }
146
+ }
147
+
148
+ func TestWorkNodeParentAndTaskOverridesAreIndependent(t *testing.T) {
149
+ client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "in_progress"})
150
+ sys := repoScopedSystem(client, nil)
151
+ cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(),
152
+ config.RawValues{"transitionTo": map[string]any{"parentStatus": "closed"}},
153
+ config.RawValues{"transitionTo": map[string]any{"taskStatus": "closed"}})
154
+
155
+ if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
156
+ t.Fatal(err)
157
+ }
158
+ if len(client.updates) != 2 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != "closed" ||
159
+ client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != "closed" {
160
+ t.Fatalf("updates = %+v, want independent mailbox and parent overrides", client.updates)
161
+ }
162
+ }
163
+
132
164
  // With nothing inherited, the built-in lifecycle values still apply: the
133
165
  // parent opens the run in_progress, a work mailbox moves to in_progress, and
134
166
  // the parent closes at end.
@@ -13,7 +13,7 @@ import (
13
13
  // mirrored by the Jira adapter.
14
14
 
15
15
  func TestNodeAssigneeOverridesInheritedRepoAssignee(t *testing.T) {
16
- client := newStatusClient(map[string]string{"demo-parent.1": "open"})
16
+ client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
17
17
  sys := repoScopedSystem(client, config.RawValues{"assignee": "repo-bot@example.com"})
18
18
  node := config.RawValues{"assignee": "dev@example.com"}
19
19
 
@@ -21,8 +21,9 @@ func TestNodeAssigneeOverridesInheritedRepoAssignee(t *testing.T) {
21
21
  if err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), cfg); err != nil {
22
22
  t.Fatal(err)
23
23
  }
24
- if len(client.updates) != 1 || client.updates[0].input.Assignee != "dev@example.com" {
25
- t.Fatalf("updates = %+v, want the node assignee to win", client.updates)
24
+ if len(client.updates) != 2 || client.updates[0].input.Assignee != "dev@example.com" ||
25
+ client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
26
+ t.Fatalf("updates = %+v, want node assignee on mailbox and work status on parent", client.updates)
26
27
  }
27
28
  }
28
29
 
@@ -115,6 +115,40 @@ func TestApplyTaskConfigParentStatusUsesReadBeforeWrite(t *testing.T) {
115
115
  }
116
116
  }
117
117
 
118
+ func TestWorkNodeParentTransitionIsIdempotentAcrossEntries(t *testing.T) {
119
+ client := newStatusClient(map[string]string{"demo-parent": "open", "demo-parent.1": "open"})
120
+ sys := &system{cli: client}
121
+ target := statusTarget("demo-parent.1")
122
+ defaults := sys.WorkDefaults()
123
+
124
+ if err := sys.ApplyTaskConfig(context.Background(), target, defaults); err != nil {
125
+ t.Fatal(err)
126
+ }
127
+ if err := sys.ApplyTaskConfig(context.Background(), target, defaults); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if len(client.updates) != 2 || client.updates[0].issueID != "demo-parent.1" || client.updates[0].input.Status != statusInProgress ||
131
+ client.updates[1].issueID != "demo-parent" || client.updates[1].input.Status != statusInProgress {
132
+ t.Fatalf("updates = %+v, want only first mailbox and parent transitions", client.updates)
133
+ }
134
+ }
135
+
136
+ func TestWorkNodeIncompatibleParentStateReturnsConflict(t *testing.T) {
137
+ client := newStatusClient(map[string]string{"demo-parent": "blocked", "demo-parent.1": "open"})
138
+ sys := &system{cli: client}
139
+
140
+ err := sys.ApplyTaskConfig(context.Background(), statusTarget("demo-parent.1"), sys.WorkDefaults())
141
+ if err == nil {
142
+ t.Fatal("incompatible parent status was accepted")
143
+ }
144
+ if got := retry.Classify(err).Kind; got != retry.Conflict {
145
+ t.Fatalf("failure kind = %q, want conflict: %v", got, err)
146
+ }
147
+ if len(client.updates) != 1 || client.updates[0].issueID != "demo-parent.1" {
148
+ t.Fatalf("updates = %+v, want only the mailbox update before parent conflict", client.updates)
149
+ }
150
+ }
151
+
118
152
  func TestCompleteMailboxUsesReadBeforeWriteAndIsIdempotent(t *testing.T) {
119
153
  client := newStatusClient(map[string]string{"demo-parent.1": "in_progress"})
120
154
  sys := &system{cli: client}
@@ -643,10 +643,14 @@ func (s *system) StartDefaults() config.RawValues {
643
643
  return s.lifecycleDefaults(transitionDefault("parentStatus", s.startStatus()))
644
644
  }
645
645
 
646
- // WorkDefaults uses the repository-selected active mailbox status; the parent
647
- // is left unchanged when parentStatus is omitted.
646
+ // WorkDefaults uses the repository-selected active status for both the parent
647
+ // and the current mailbox.
648
648
  func (s *system) WorkDefaults() config.RawValues {
649
- return s.lifecycleDefaults(transitionDefault("taskStatus", s.workStatus()))
649
+ status := s.workStatus()
650
+ return s.lifecycleDefaults(config.RawValues{"transitionTo": map[string]any{
651
+ "parentStatus": status,
652
+ "taskStatus": status,
653
+ }})
650
654
  }
651
655
 
652
656
  // EndDefaults uses the repository-selected parent completion status.
@@ -740,9 +744,9 @@ func (s *system) EnsureMailboxes(ctx context.Context, parent task.TicketRef, wor
740
744
 
741
745
  // ApplyTaskConfig applies the adapter-owned taskConfig to the parent and
742
746
  // optional mailbox. An omitted work-node taskStatus uses the repository's
743
- // selected work status and leaves the parent unchanged; an omitted
744
- // parent-only parentStatus uses the selected start status. Run orchestration
745
- // merges EndDefaults into the end node's config before that call.
747
+ // selected work status. Run orchestration merges WorkDefaults or EndDefaults
748
+ // into the node config before that call, so omitted work-node defaults apply
749
+ // the selected work status to both the parent and mailbox.
746
750
  func (s *system) ApplyTaskConfig(ctx context.Context, target task.Target, taskConfig config.RawValues) error {
747
751
  cfg, err := decodeConfig(taskConfig)
748
752
  if err != nil {
@@ -85,6 +85,9 @@ func TestLifecycleDefaultsCarryInheritedTransitionTo(t *testing.T) {
85
85
  t.Fatalf("mailbox transitions = %v, want the inherited repo value to beat the registered lifecycle value",
86
86
  fake.taskTransitions)
87
87
  }
88
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != defaultWorkTaskStatus {
89
+ t.Fatalf("parent transitions = %v, want the built-in work value", fake.parentTransitions)
90
+ }
88
91
  })
89
92
  }
90
93
 
@@ -160,8 +163,8 @@ func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
160
163
  if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, operationConfig(d.EndDefaults(), nil, nil)); err != nil {
161
164
  t.Fatal(err)
162
165
  }
163
- if len(fake.parentTransitions) != 2 || fake.parentTransitions[0] != defaultStartParentStatus || fake.parentTransitions[1] != defaultEndParentStatus {
164
- t.Fatalf("parent transitions = %v, want [%s %s]", fake.parentTransitions, defaultStartParentStatus, defaultEndParentStatus)
166
+ if len(fake.parentTransitions) != 3 || fake.parentTransitions[0] != defaultStartParentStatus || fake.parentTransitions[1] != defaultWorkTaskStatus || fake.parentTransitions[2] != defaultEndParentStatus {
167
+ t.Fatalf("parent transitions = %v, want [%s %s %s]", fake.parentTransitions, defaultStartParentStatus, defaultWorkTaskStatus, defaultEndParentStatus)
165
168
  }
166
169
  if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != defaultWorkTaskStatus {
167
170
  t.Fatalf("mailbox transitions = %v, want [%s]", fake.taskTransitions, defaultWorkTaskStatus)
@@ -170,3 +173,23 @@ func TestLifecycleDefaultsWithoutInheritedValues(t *testing.T) {
170
173
  t.Fatalf("assignments = %v, want none when no assignee is configured", fake.assignments)
171
174
  }
172
175
  }
176
+
177
+ func TestWorkNodeParentAndTaskOverridesAreIndependent(t *testing.T) {
178
+ fake := &fakeJira{}
179
+ sys := repoScopedSystem(t, fake, nil)
180
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
181
+ mailbox := &task.Mailbox{ID: "2", Key: "PAY-102", Node: "implement"}
182
+ cfg := operationConfig(lifecycleDefaultsOf(t, sys).WorkDefaults(),
183
+ config.RawValues{"transitionTo": map[string]any{"parentStatus": "Workflow Parent"}},
184
+ config.RawValues{"transitionTo": map[string]any{"taskStatus": "Node Task"}})
185
+
186
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: mailbox}, cfg); err != nil {
187
+ t.Fatal(err)
188
+ }
189
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Workflow Parent" {
190
+ t.Fatalf("parent transitions = %v, want workflow parent override", fake.parentTransitions)
191
+ }
192
+ if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "Node Task" {
193
+ t.Fatalf("mailbox transitions = %v, want node task override", fake.taskTransitions)
194
+ }
195
+ }
@@ -97,10 +97,8 @@ func inlineContent(parent ast.Node, source []byte, marks []any) []any {
97
97
  if value := string(node.Text(source)); value != "" {
98
98
  content = append(content, textNode(value, marks))
99
99
  }
100
- if node.HardLineBreak() {
100
+ if node.HardLineBreak() || node.SoftLineBreak() {
101
101
  content = append(content, map[string]any{"type": "hardBreak"})
102
- } else if node.SoftLineBreak() {
103
- content = append(content, textNode(" ", marks))
104
102
  }
105
103
  case *ast.String:
106
104
  if value := string(node.Value); value != "" {
@@ -146,20 +144,31 @@ func textNode(value string, marks []any) map[string]any {
146
144
 
147
145
  func ADFText(raw json.RawMessage) string {
148
146
  var node struct {
147
+ Type string `json:"type"`
149
148
  Text string `json:"text"`
150
149
  Content []json.RawMessage `json:"content"`
151
150
  }
152
151
  if json.Unmarshal(raw, &node) != nil {
153
152
  return ""
154
153
  }
154
+ if node.Type == "hardBreak" {
155
+ return "\n"
156
+ }
157
+ if len(node.Content) == 0 {
158
+ return node.Text
159
+ }
160
+
161
+ separator := ""
162
+ switch node.Type {
163
+ case "doc", "blockquote", "bulletList", "orderedList", "listItem":
164
+ separator = "\n"
165
+ }
155
166
  parts := make([]string, 0, len(node.Content)+1)
156
167
  if node.Text != "" {
157
168
  parts = append(parts, node.Text)
158
169
  }
159
170
  for _, child := range node.Content {
160
- if text := ADFText(child); text != "" {
161
- parts = append(parts, text)
162
- }
171
+ parts = append(parts, ADFText(child))
163
172
  }
164
- return strings.Join(parts, "\n")
173
+ return strings.Join(parts, separator)
165
174
  }
@@ -48,13 +48,93 @@ func TestADFDoesNotInferFormatting(t *testing.T) {
48
48
  }
49
49
  }
50
50
 
51
+ func TestADFPreservesTaskTextLineBreaks(t *testing.T) {
52
+ value := strings.Join([]string{
53
+ "Parent ticket: PAY-1",
54
+ "Workflow: relayFlow",
55
+ "Node: coding",
56
+ "Node type: agent",
57
+ "Agent: coder",
58
+ "Mailbox: PAY-1:coding",
59
+ "Node work: inspect the change",
60
+ "Keep this instruction on its own line",
61
+ }, "\n")
62
+ doc := ADF(value)
63
+ paragraph := doc["content"].([]any)[0].(map[string]any)
64
+ inline := paragraph["content"].([]any)
65
+ hardBreaks := 0
66
+ for _, item := range inline {
67
+ if item.(map[string]any)["type"] == "hardBreak" {
68
+ hardBreaks++
69
+ }
70
+ }
71
+ if hardBreaks != strings.Count(value, "\n") {
72
+ t.Fatalf("hard break count = %d, want %d: %#v", hardBreaks, strings.Count(value, "\n"), inline)
73
+ }
74
+ raw, err := json.Marshal(doc)
75
+ if err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ if got := ADFText(raw); got != value {
79
+ t.Fatalf("ADFText() = %q, want %q", got, value)
80
+ }
81
+ }
82
+
83
+ func TestADFReportLabelsRemainSeparate(t *testing.T) {
84
+ value := strings.Join([]string{
85
+ "STATUS: success",
86
+ "NEXT STEP: reviewer",
87
+ "",
88
+ "SUMMARY:",
89
+ "COMPLETED: implemented",
90
+ "COMMITS: abc123",
91
+ "NOT COMPLETED: None",
92
+ "ISSUES DISCOVERED: None",
93
+ "VERIFICATION: go test ./...",
94
+ "NOTES: None",
95
+ "",
96
+ "FEEDBACK:",
97
+ "REASON FOR NEXT STEP: ready",
98
+ "REQUIRED ACTIONS: None",
99
+ "RELEVANT CONTEXT: None",
100
+ "EXPECTED RESULT: review",
101
+ }, "\n")
102
+ raw, err := json.Marshal(ADF(value))
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ got := ADFText(raw)
107
+ for _, want := range []string{
108
+ "STATUS: success", "NEXT STEP: reviewer", "SUMMARY:",
109
+ "COMPLETED: implemented", "COMMITS: abc123", "NOT COMPLETED: None",
110
+ "ISSUES DISCOVERED: None", "VERIFICATION: go test ./...", "NOTES: None",
111
+ "FEEDBACK:", "REASON FOR NEXT STEP: ready", "REQUIRED ACTIONS: None",
112
+ "RELEVANT CONTEXT: None", "EXPECTED RESULT: review",
113
+ } {
114
+ found := false
115
+ for _, line := range strings.Split(got, "\n") {
116
+ if line == want {
117
+ found = true
118
+ break
119
+ }
120
+ }
121
+ if !found {
122
+ t.Errorf("ADFText() = %q, missing separate report line %q", got, want)
123
+ }
124
+ }
125
+ }
126
+
51
127
  func TestADFMarkerRoundTrip(t *testing.T) {
52
128
  const marker = "<!-- run-1:summary -->"
53
- raw, err := json.Marshal(ADF("Done.\n\n" + marker))
129
+ raw, err := json.Marshal(ADF("Done on line one\nDone on line two\n\n" + marker))
54
130
  if err != nil {
55
131
  t.Fatal(err)
56
132
  }
57
- if got := ADFText(raw); !strings.Contains(got, marker) {
133
+ got := ADFText(raw)
134
+ if !strings.Contains(got, "Done on line one\nDone on line two") {
135
+ t.Fatalf("ADFText() lost hard break: %q", got)
136
+ }
137
+ if !strings.Contains(got, marker) {
58
138
  t.Fatalf("ADFText() = %q, want marker %q", got, marker)
59
139
  }
60
140
  }
@@ -183,6 +183,9 @@ func TestCreateSubtasksBatchesAndIncludesADFParentAndLabel(t *testing.T) {
183
183
  if !strings.Contains(string(update.Fields["description"]), `"type":"doc"`) {
184
184
  t.Fatalf("description is not ADF: %s", update.Fields["description"])
185
185
  }
186
+ if got := ADFText(update.Fields["description"]); got != "Work:\nDo it" {
187
+ t.Fatalf("description lost its line break: %q", got)
188
+ }
186
189
  created[i] = map[string]any{"id": fmt.Sprint(i + 1), "key": fmt.Sprintf("PAY-%d", i+2)}
187
190
  }
188
191
  writeJSON(w, map[string]any{"issues": created, "errors": []any{}})
@@ -274,6 +277,17 @@ func TestUpdateMailboxIsOneCall(t *testing.T) {
274
277
  if !strings.Contains(string(body), `"description"`) || !strings.Contains(string(body), `"labels"`) {
275
278
  t.Fatalf("combined update missing fields: %s", body)
276
279
  }
280
+ var request struct {
281
+ Fields struct {
282
+ Description json.RawMessage `json:"description"`
283
+ } `json:"fields"`
284
+ }
285
+ if err := json.Unmarshal(body, &request); err != nil {
286
+ t.Fatal(err)
287
+ }
288
+ if got := ADFText(request.Fields.Description); got != "Work:\nDo it" {
289
+ t.Fatalf("mailbox description lost its line break: %q", got)
290
+ }
277
291
  w.WriteHeader(http.StatusNoContent)
278
292
  })
279
293
  if err := s.client(t).UpdateMailbox(context.Background(), "PAY-2", "Work:\nDo it", "wf:flow"); err != nil {
@@ -338,6 +352,15 @@ func TestCommentsUseADFAndParseMarker(t *testing.T) {
338
352
  if !strings.Contains(string(body), `"type":"doc"`) {
339
353
  t.Fatalf("comment is not ADF: %s", body)
340
354
  }
355
+ var request struct {
356
+ Body json.RawMessage `json:"body"`
357
+ }
358
+ if err := json.Unmarshal(body, &request); err != nil {
359
+ t.Fatal(err)
360
+ }
361
+ if got := ADFText(request.Body); got != "SUMMARY:\nDone" {
362
+ t.Fatalf("comment lost its line break: %q", got)
363
+ }
341
364
  w.WriteHeader(http.StatusCreated)
342
365
  })
343
366
  c := s.client(t)
@@ -350,6 +373,37 @@ func TestCommentsUseADFAndParseMarker(t *testing.T) {
350
373
  }
351
374
  }
352
375
 
376
+ func TestCommentsPreserveMultilineReportsAndMarkers(t *testing.T) {
377
+ want := []string{
378
+ "SUMMARY:\nCOMPLETED: implemented\nCOMMITS: abc123",
379
+ "FEEDBACK:\nREASON FOR NEXT STEP: ready\nREQUIRED ACTIONS: fix it\n<!-- visit:feedback -->",
380
+ }
381
+ got := make([]string, 0, len(want))
382
+ s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, body []byte) {
383
+ if r.Method != http.MethodPost || r.URL.Path != "/rest/api/3/issue/PAY-2/comment" {
384
+ http.NotFound(w, r)
385
+ return
386
+ }
387
+ var request struct {
388
+ Body json.RawMessage `json:"body"`
389
+ }
390
+ if err := json.Unmarshal(body, &request); err != nil {
391
+ t.Fatal(err)
392
+ }
393
+ got = append(got, ADFText(request.Body))
394
+ w.WriteHeader(http.StatusCreated)
395
+ })
396
+ client := s.client(t)
397
+ for _, body := range want {
398
+ if err := client.AddComment(context.Background(), "PAY-2", body); err != nil {
399
+ t.Fatal(err)
400
+ }
401
+ }
402
+ if !reflect.DeepEqual(got, want) {
403
+ t.Fatalf("comments = %#v, want %#v", got, want)
404
+ }
405
+ }
406
+
353
407
  func TestRetries429WithoutExposingCredentials(t *testing.T) {
354
408
  calls := 0
355
409
  s := newJiraServer(t, func(w http.ResponseWriter, r *http.Request, _ []byte) {
@@ -54,8 +54,8 @@ func TestRepositoryStatusDefaultsDriveLifecycleAndMailboxCompletion(t *testing.T
54
54
  if err := sys.CompleteMailbox(context.Background(), mailbox); err != nil {
55
55
  t.Fatal(err)
56
56
  }
57
- if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Open" {
58
- t.Fatalf("parent transitions = %v, want [Open]", fake.parentTransitions)
57
+ if len(fake.parentTransitions) != 2 || fake.parentTransitions[0] != "Open" || fake.parentTransitions[1] != "Working" {
58
+ t.Fatalf("parent transitions = %v, want [Open Working]", fake.parentTransitions)
59
59
  }
60
60
  if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "Working" || fake.taskTransitions[1] != "Closed" {
61
61
  t.Fatalf("mailbox transitions = %v, want [Working Closed]", fake.taskTransitions)
@@ -11,8 +11,8 @@ import (
11
11
  )
12
12
 
13
13
  // Jira repository status defaults are deterministic: omitted transitions use
14
- // the registered start/work/end values, and an omitted work-node parent
15
- // transition leaves the parent unchanged.
14
+ // the registered start/work/end values, and omitted work-node defaults apply
15
+ // the work status to both the parent and mailbox.
16
16
  //
17
17
  // This test is package-local (package jira) so it can drive the adapter's
18
18
  // ApplyTaskConfig against a test-local fake client without inventing an
@@ -67,20 +67,21 @@ func TestStartDefaultParentInProgress(t *testing.T) {
67
67
  }
68
68
  }
69
69
 
70
- func TestWorkNodeDefaultMailboxInProgressParentUnchanged(t *testing.T) {
70
+ func TestWorkNodeDefaultParentAndMailboxInProgress(t *testing.T) {
71
71
  fake := &fakeJira{}
72
72
  sys := newSystemWithFake(t, fake)
73
73
  parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
74
74
  mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
75
75
 
76
- if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{}); err != nil {
76
+ defaults := sys.(task.LifecycleDefaults).WorkDefaults()
77
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, defaults); err != nil {
77
78
  t.Fatalf("ApplyTaskConfig failed: %v", err)
78
79
  }
79
80
  if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Progress" {
80
81
  t.Fatalf("mailbox transitions = %v, want [In Progress]", fake.taskTransitions)
81
82
  }
82
- if len(fake.parentTransitions) != 0 {
83
- t.Fatalf("parent transitions = %v, want none (parent unchanged when omitted)", fake.parentTransitions)
83
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
84
+ t.Fatalf("parent transitions = %v, want [In Progress]", fake.parentTransitions)
84
85
  }
85
86
  if len(fake.assignments) != 0 {
86
87
  t.Fatalf("mailbox assignments = %v, want none when assignee omitted", fake.assignments)
@@ -93,17 +94,21 @@ func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
93
94
  parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
94
95
  mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
95
96
 
96
- err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
97
- "assignee": "reviewer@example.com",
98
- })
97
+ err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.Merge(
98
+ sys.(task.LifecycleDefaults).WorkDefaults(),
99
+ config.RawValues{"assignee": "reviewer@example.com"},
100
+ ))
99
101
  if err != nil {
100
102
  t.Fatalf("ApplyTaskConfig failed: %v", err)
101
103
  }
102
104
  if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:reviewer@example.com" {
103
105
  t.Fatalf("mailbox assignments = %v, want [PAY-102:reviewer@example.com]", fake.assignments)
104
106
  }
105
- if len(fake.events) != 1 || fake.events[0] != "transition" {
106
- t.Fatalf("mailbox events = %v, want one combined transition", fake.events)
107
+ if len(fake.events) != 2 || fake.events[0] != "transition" || fake.events[1] != "transition" {
108
+ t.Fatalf("transition events = %v, want mailbox and parent transitions", fake.events)
109
+ }
110
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
111
+ t.Fatalf("parent transitions = %v, want [In Progress]", fake.parentTransitions)
107
112
  }
108
113
  }
109
114
 
@@ -178,6 +183,27 @@ func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
178
183
  }
179
184
  }
180
185
 
186
+ func TestWorkDefaultsUseConfiguredWorkStatusForParentAndMailbox(t *testing.T) {
187
+ fake := &fakeJira{}
188
+ sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
189
+ Name: "payments",
190
+ RepoConfig: config.RawValues{
191
+ "project": "PAY", "component": "api",
192
+ "statusDefaults": map[string]any{"start": "Open", "work": "Working", "end": "Closed"},
193
+ },
194
+ })
195
+ if err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ transition, ok := sys.WorkDefaults()["transitionTo"].(map[string]any)
199
+ if !ok {
200
+ t.Fatalf("WorkDefaults transitionTo = %#v, want map", sys.WorkDefaults()["transitionTo"])
201
+ }
202
+ if transition["parentStatus"] != "Working" || transition["taskStatus"] != "Working" {
203
+ t.Fatalf("WorkDefaults transitionTo = %#v, want both statuses Working", transition)
204
+ }
205
+ }
206
+
181
207
  func TestExplicitTransitionsWin(t *testing.T) {
182
208
  fake := &fakeJira{}
183
209
  sys := newSystemWithFake(t, fake)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.3.1-alpha",
3
+ "version": "0.3.3-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",