relay-flow 0.2.1-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 (72) hide show
  1. package/README.md +155 -22
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +74 -0
  4. package/cmd/relay-flow/main.go +25 -0
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +4 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  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 +105 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine_test.go +33 -15
  16. package/internal/execution/goworkflows/fakes_test.go +50 -4
  17. package/internal/execution/goworkflows/interpreter.go +36 -29
  18. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  19. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  20. package/internal/execution/goworkflows/recovery_test.go +2 -2
  21. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  22. package/internal/harness/contract_test.go +10 -0
  23. package/internal/harness/factory.go +25 -3
  24. package/internal/harness/harness.go +30 -4
  25. package/internal/harness/opencode/opencode.go +125 -10
  26. package/internal/harness/opencode/opencode_test.go +183 -0
  27. package/internal/harness/opencode/repo_setup.go +361 -0
  28. package/internal/harness/plugin_selection_test.go +5 -5
  29. package/internal/recover/recover.go +11 -6
  30. package/internal/repo/service.go +10 -0
  31. package/internal/repo/service_test.go +51 -2
  32. package/internal/run/run.go +6 -4
  33. package/internal/task/beads/bdcli/bdcli.go +323 -0
  34. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  35. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  36. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  37. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  38. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  39. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  40. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  41. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  42. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  43. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  44. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  45. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  46. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  47. package/internal/task/beads/beads.go +840 -0
  48. package/internal/task/beads/beads_test.go +609 -0
  49. package/internal/task/beads/comments_test.go +242 -0
  50. package/internal/task/beads/config_compatibility_test.go +163 -0
  51. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  52. package/internal/task/beads/repo_composition_test.go +232 -0
  53. package/internal/task/beads/runtime_config_test.go +81 -0
  54. package/internal/task/beads/status_compatibility_test.go +233 -0
  55. package/internal/task/beads/status_test.go +257 -0
  56. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  57. package/internal/task/beads/validation_test.go +110 -0
  58. package/internal/task/contract_test.go +10 -0
  59. package/internal/task/factory.go +37 -4
  60. package/internal/task/jira/auth.go +27 -1
  61. package/internal/task/jira/auth_test.go +54 -1
  62. package/internal/task/jira/filters_test.go +60 -0
  63. package/internal/task/jira/jira.go +171 -29
  64. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  65. package/internal/task/jira/rest/adf.go +119 -82
  66. package/internal/task/jira/rest/adf_test.go +60 -0
  67. package/internal/task/jira/rest/client_test.go +1 -1
  68. package/internal/task/jira/templates_test.go +118 -0
  69. package/internal/task/jira/transition_defaults_test.go +4 -2
  70. package/internal/task/task.go +32 -0
  71. package/internal/workflow/report_test.go +45 -0
  72. package/package.json +1 -1
@@ -0,0 +1,361 @@
1
+ package opencode
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ )
12
+
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.2-alpha"
14
+
15
+ type jsoncToken struct {
16
+ kind byte
17
+ start, end int
18
+ value string
19
+ }
20
+
21
+ type jsoncValue struct {
22
+ kind byte
23
+ startToken int
24
+ endToken int
25
+ members []jsoncMember
26
+ elements []jsoncValue
27
+ closeToken int
28
+ }
29
+
30
+ type jsoncMember struct {
31
+ name string
32
+ value jsoncValue
33
+ }
34
+
35
+ type jsoncParser struct {
36
+ tokens []jsoncToken
37
+ index int
38
+ }
39
+
40
+ type textEdit struct {
41
+ start, end int
42
+ text string
43
+ }
44
+
45
+ func setupRepo(repoPath string) error {
46
+ path, mode, err := openCodeConfigPath(repoPath)
47
+ if err != nil {
48
+ return err
49
+ }
50
+ data, err := os.ReadFile(path)
51
+ if err != nil {
52
+ if !os.IsNotExist(err) {
53
+ return fmt.Errorf("opencode: read %s: %w", path, err)
54
+ }
55
+ data = []byte("{\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
56
+ } else {
57
+ data, err = updateOpenCodeConfig(data)
58
+ if err != nil {
59
+ return fmt.Errorf("opencode: update %s: %w", path, err)
60
+ }
61
+ }
62
+ if existing, readErr := os.ReadFile(path); readErr == nil && string(existing) == string(data) {
63
+ return nil
64
+ }
65
+ if err := config.WriteAtomic(path, data, mode); err != nil {
66
+ return fmt.Errorf("opencode: write config: %w", err)
67
+ }
68
+ return nil
69
+ }
70
+
71
+ func openCodeConfigPath(repoPath string) (string, os.FileMode, error) {
72
+ jsonPath := filepath.Join(repoPath, "opencode.json")
73
+ jsoncPath := filepath.Join(repoPath, "opencode.jsonc")
74
+ for _, path := range []string{jsonPath, jsoncPath} {
75
+ info, err := os.Stat(path)
76
+ if err == nil {
77
+ if !info.Mode().IsRegular() {
78
+ return "", 0, fmt.Errorf("opencode: config %s is not a regular file", path)
79
+ }
80
+ return path, info.Mode().Perm(), nil
81
+ }
82
+ if !os.IsNotExist(err) {
83
+ return "", 0, fmt.Errorf("opencode: inspect %s: %w", path, err)
84
+ }
85
+ }
86
+ return jsonPath, 0o644, nil
87
+ }
88
+
89
+ func updateOpenCodeConfig(data []byte) ([]byte, error) {
90
+ tokens, err := tokenizeJSONC(data)
91
+ if err != nil {
92
+ return nil, err
93
+ }
94
+ p := jsoncParser{tokens: tokens}
95
+ root, err := p.parseValue()
96
+ if err != nil {
97
+ return nil, err
98
+ }
99
+ if p.index != len(tokens) || root.kind != '{' {
100
+ return nil, fmt.Errorf("config root must be one JSON object")
101
+ }
102
+
103
+ var plugins *jsoncValue
104
+ for i := range root.members {
105
+ if root.members[i].name == "plugin" {
106
+ if plugins != nil {
107
+ return nil, fmt.Errorf("duplicate plugin property")
108
+ }
109
+ plugins = &root.members[i].value
110
+ }
111
+ }
112
+ quoted, _ := json.Marshal(relayFlowPlugin)
113
+ if plugins == nil {
114
+ return addPluginProperty(data, tokens, root, string(quoted)), nil
115
+ }
116
+ if plugins.kind != '[' {
117
+ return nil, fmt.Errorf("plugin property must be an array")
118
+ }
119
+
120
+ first := -1
121
+ duplicates := make([]int, 0)
122
+ for i, element := range plugins.elements {
123
+ tok := tokens[element.startToken]
124
+ if element.kind != 's' {
125
+ return nil, fmt.Errorf("plugin array element %d must be a string", i)
126
+ }
127
+ if tok.value == "relay-flow-plugin" || strings.HasPrefix(tok.value, "relay-flow-plugin@") {
128
+ if first < 0 {
129
+ first = i
130
+ } else {
131
+ duplicates = append(duplicates, i)
132
+ }
133
+ }
134
+ }
135
+
136
+ edits := make([]textEdit, 0, len(duplicates)+1)
137
+ if first < 0 {
138
+ edits = append(edits, addArrayElementEdit(tokens, *plugins, string(quoted)))
139
+ } else {
140
+ tok := tokens[plugins.elements[first].startToken]
141
+ edits = append(edits, textEdit{start: tok.start, end: tok.end, text: string(quoted)})
142
+ for _, index := range duplicates {
143
+ element := plugins.elements[index]
144
+ comma := commaBefore(tokens, plugins.elements[index-1].endToken, element.startToken)
145
+ edits = append(edits,
146
+ textEdit{start: tokens[comma].start, end: tokens[comma].end},
147
+ textEdit{start: tokens[element.startToken].start, end: tokens[element.endToken].end},
148
+ )
149
+ }
150
+ }
151
+ return applyTextEdits(data, edits), nil
152
+ }
153
+
154
+ func addPluginProperty(data []byte, tokens []jsoncToken, root jsoncValue, quoted string) []byte {
155
+ closeAt := tokens[root.closeToken].start
156
+ edits := []textEdit{{
157
+ start: closeAt,
158
+ end: closeAt,
159
+ text: "\n \"plugin\": [" + quoted + "]\n",
160
+ }}
161
+ if len(root.members) > 0 {
162
+ last := root.members[len(root.members)-1].value.endToken
163
+ if commaBefore(tokens, last, root.closeToken) < 0 {
164
+ at := tokens[last].end
165
+ edits = append(edits, textEdit{start: at, end: at, text: ","})
166
+ }
167
+ }
168
+ return applyTextEdits(data, edits)
169
+ }
170
+
171
+ func addArrayElementEdit(tokens []jsoncToken, array jsoncValue, quoted string) textEdit {
172
+ if len(array.elements) == 0 {
173
+ at := tokens[array.closeToken].start
174
+ return textEdit{start: at, end: at, text: quoted}
175
+ }
176
+ last := array.elements[len(array.elements)-1]
177
+ if comma := commaBefore(tokens, last.endToken, array.closeToken); comma >= 0 {
178
+ at := tokens[comma].end
179
+ return textEdit{start: at, end: at, text: " " + quoted}
180
+ }
181
+ at := tokens[last.endToken].end
182
+ return textEdit{start: at, end: at, text: ", " + quoted}
183
+ }
184
+
185
+ func commaBefore(tokens []jsoncToken, after, before int) int {
186
+ for i := after + 1; i < before; i++ {
187
+ if tokens[i].kind == ',' {
188
+ return i
189
+ }
190
+ }
191
+ return -1
192
+ }
193
+
194
+ func applyTextEdits(data []byte, edits []textEdit) []byte {
195
+ for i := 0; i < len(edits); i++ {
196
+ for j := i + 1; j < len(edits); j++ {
197
+ if edits[j].start > edits[i].start {
198
+ edits[i], edits[j] = edits[j], edits[i]
199
+ }
200
+ }
201
+ }
202
+ out := append([]byte(nil), data...)
203
+ for _, edit := range edits {
204
+ out = append(out[:edit.start], append([]byte(edit.text), out[edit.end:]...)...)
205
+ }
206
+ return out
207
+ }
208
+
209
+ func tokenizeJSONC(data []byte) ([]jsoncToken, error) {
210
+ tokens := make([]jsoncToken, 0)
211
+ for i := 0; i < len(data); {
212
+ switch data[i] {
213
+ case ' ', '\t', '\r', '\n':
214
+ i++
215
+ continue
216
+ case '/':
217
+ if i+1 >= len(data) {
218
+ return nil, fmt.Errorf("unexpected slash at byte %d", i)
219
+ }
220
+ if data[i+1] == '/' {
221
+ i += 2
222
+ for i < len(data) && data[i] != '\n' {
223
+ i++
224
+ }
225
+ continue
226
+ }
227
+ if data[i+1] == '*' {
228
+ start := i
229
+ i += 2
230
+ for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') {
231
+ i++
232
+ }
233
+ if i+1 >= len(data) {
234
+ return nil, fmt.Errorf("unterminated comment at byte %d", start)
235
+ }
236
+ i += 2
237
+ continue
238
+ }
239
+ return nil, fmt.Errorf("unexpected slash at byte %d", i)
240
+ case '{', '}', '[', ']', ':', ',':
241
+ tokens = append(tokens, jsoncToken{kind: data[i], start: i, end: i + 1})
242
+ i++
243
+ case '"':
244
+ start := i
245
+ i++
246
+ for i < len(data) {
247
+ if data[i] == '\\' {
248
+ i += 2
249
+ continue
250
+ }
251
+ if data[i] == '"' {
252
+ i++
253
+ break
254
+ }
255
+ i++
256
+ }
257
+ if i > len(data) || data[i-1] != '"' {
258
+ return nil, fmt.Errorf("unterminated string at byte %d", start)
259
+ }
260
+ var value string
261
+ if err := json.Unmarshal(data[start:i], &value); err != nil {
262
+ return nil, fmt.Errorf("invalid string at byte %d: %w", start, err)
263
+ }
264
+ tokens = append(tokens, jsoncToken{kind: 's', start: start, end: i, value: value})
265
+ default:
266
+ start := i
267
+ for i < len(data) && !strings.ContainsRune(" \t\r\n{}[]:,/", rune(data[i])) {
268
+ i++
269
+ }
270
+ if start == i || !json.Valid(data[start:i]) {
271
+ return nil, fmt.Errorf("invalid value at byte %d", start)
272
+ }
273
+ tokens = append(tokens, jsoncToken{kind: 'v', start: start, end: i})
274
+ }
275
+ }
276
+ return tokens, nil
277
+ }
278
+
279
+ func (p *jsoncParser) parseValue() (jsoncValue, error) {
280
+ if p.index >= len(p.tokens) {
281
+ return jsoncValue{}, fmt.Errorf("expected value")
282
+ }
283
+ start := p.index
284
+ tok := p.tokens[p.index]
285
+ switch tok.kind {
286
+ case 's', 'v':
287
+ p.index++
288
+ return jsoncValue{kind: tok.kind, startToken: start, endToken: start}, nil
289
+ case '{':
290
+ return p.parseObject()
291
+ case '[':
292
+ return p.parseArray()
293
+ default:
294
+ return jsoncValue{}, fmt.Errorf("unexpected token %q at byte %d", tok.kind, tok.start)
295
+ }
296
+ }
297
+
298
+ func (p *jsoncParser) parseObject() (jsoncValue, error) {
299
+ value := jsoncValue{kind: '{', startToken: p.index}
300
+ p.index++
301
+ for {
302
+ if p.index >= len(p.tokens) {
303
+ return jsoncValue{}, fmt.Errorf("unterminated object")
304
+ }
305
+ if p.tokens[p.index].kind == '}' {
306
+ value.closeToken = p.index
307
+ value.endToken = p.index
308
+ p.index++
309
+ return value, nil
310
+ }
311
+ key := p.tokens[p.index]
312
+ if key.kind != 's' {
313
+ return jsoncValue{}, fmt.Errorf("object key at byte %d must be a string", key.start)
314
+ }
315
+ p.index++
316
+ if p.index >= len(p.tokens) || p.tokens[p.index].kind != ':' {
317
+ return jsoncValue{}, fmt.Errorf("object key %q is missing a colon", key.value)
318
+ }
319
+ p.index++
320
+ memberValue, err := p.parseValue()
321
+ if err != nil {
322
+ return jsoncValue{}, err
323
+ }
324
+ value.members = append(value.members, jsoncMember{name: key.value, value: memberValue})
325
+ if p.index < len(p.tokens) && p.tokens[p.index].kind == ',' {
326
+ p.index++
327
+ continue
328
+ }
329
+ if p.index >= len(p.tokens) || p.tokens[p.index].kind != '}' {
330
+ return jsoncValue{}, fmt.Errorf("object member %q is missing a comma", key.value)
331
+ }
332
+ }
333
+ }
334
+
335
+ func (p *jsoncParser) parseArray() (jsoncValue, error) {
336
+ value := jsoncValue{kind: '[', startToken: p.index}
337
+ p.index++
338
+ for {
339
+ if p.index >= len(p.tokens) {
340
+ return jsoncValue{}, fmt.Errorf("unterminated array")
341
+ }
342
+ if p.tokens[p.index].kind == ']' {
343
+ value.closeToken = p.index
344
+ value.endToken = p.index
345
+ p.index++
346
+ return value, nil
347
+ }
348
+ element, err := p.parseValue()
349
+ if err != nil {
350
+ return jsoncValue{}, err
351
+ }
352
+ value.elements = append(value.elements, element)
353
+ if p.index < len(p.tokens) && p.tokens[p.index].kind == ',' {
354
+ p.index++
355
+ continue
356
+ }
357
+ if p.index >= len(p.tokens) || p.tokens[p.index].kind != ']' {
358
+ return jsoncValue{}, fmt.Errorf("array element is missing a comma")
359
+ }
360
+ }
361
+ }
@@ -20,9 +20,9 @@ import (
20
20
  // panics.
21
21
 
22
22
  func TestHarnessRegistryUnknownNameListsRegistered(t *testing.T) {
23
- harness.Register("fakeharness-unknown", func(config.RawValues) (harness.Harness, error) {
23
+ harness.Register("fakeharness-unknown", harness.Factory{New: func(config.RawValues) (harness.Harness, error) {
24
24
  return newFakeHarness(), nil
25
- })
25
+ }})
26
26
  _, err := harness.New("does-not-exist", config.RawValues{})
27
27
  if err == nil {
28
28
  t.Fatal("unknown harness name accepted")
@@ -75,9 +75,9 @@ func TestDuplicateRegistrationPanics(t *testing.T) {
75
75
  fn()
76
76
  }
77
77
 
78
- harness.Register("dup-harness-x", func(config.RawValues) (harness.Harness, error) { return nil, nil })
78
+ harness.Register("dup-harness-x", harness.Factory{New: func(config.RawValues) (harness.Harness, error) { return nil, nil }})
79
79
  assertPanic("harness", func() {
80
- harness.Register("dup-harness-x", func(config.RawValues) (harness.Harness, error) { return nil, nil })
80
+ harness.Register("dup-harness-x", harness.Factory{New: func(config.RawValues) (harness.Harness, error) { return nil, nil }})
81
81
  })
82
82
 
83
83
  runner.Register("dup-runner-x", func(config.RawValues) (runner.Runner, error) { return nil, nil })
@@ -97,7 +97,7 @@ func TestDuplicateRegistrationPanics(t *testing.T) {
97
97
  func TestNamesListsRegistered(t *testing.T) {
98
98
  // Self-contained: register fixtures within this test rather than relying
99
99
  // on earlier tests in the binary.
100
- harness.Register("names-harness-x", func(config.RawValues) (harness.Harness, error) { return nil, nil })
100
+ harness.Register("names-harness-x", harness.Factory{New: func(config.RawValues) (harness.Harness, error) { return nil, nil }})
101
101
  runner.Register("names-runner-x", func(config.RawValues) (runner.Runner, error) { return nil, nil })
102
102
  task.Register("names-task-x", task.Factory{
103
103
  RequiredRepoKeys: func() []string { return nil },
@@ -37,11 +37,10 @@ import (
37
37
  "github.com/rajpopat27/relay-flow/internal/workflow"
38
38
  )
39
39
 
40
- // MailboxSpecFor builds one spec per agent/HITL work node for a ticket. The
41
- // engine's MailboxSpecs supplies the production description content; this
40
+ // MailboxSpecFor builds and task-system-renders one spec per work node. This
42
41
  // indirection keeps recover decoupled from the engine package (which imports
43
42
  // internal/run and would otherwise form a cycle).
44
- type MailboxSpecFor func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec
43
+ type MailboxSpecFor func(task.System, run.Work, *workflow.Workflow) ([]task.MailboxSpec, error)
45
44
 
46
45
  // FromTaskSystem is the `serve --recover` composition. Explicit flag only;
47
46
  // never automatic; database loss is never inferred.
@@ -84,9 +83,15 @@ func FromTaskSystem(ctx context.Context, repoReg *repo.Registry, rnr runner.Runn
84
83
  }); err != nil {
85
84
  return fmt.Errorf("repo %q ticket %s close terminals: %w", rp.Name, ticket.Key, err)
86
85
  }
87
- // EnsureMailboxes: find existing, create only missing. Specs are
88
- // built by the caller (the engine supplies production content).
89
- mailboxes, err := rp.TaskSystem.EnsureMailboxes(ctx, ticket.Ref(), wf.Name, specsFor(wf, ticket.Key))
86
+ // EnsureMailboxes: find existing, create only missing. The selected
87
+ // task system renders its customizable description before the fixed
88
+ // report contract and legal routes are supplied.
89
+ work := run.Work{RunID: runID, Repo: rp.Name, Workflow: wf.Name, Parent: ticket.Ref(), WorkflowTaskConfig: wf.TaskConfig}
90
+ specs, err := specsFor(rp.TaskSystem, work, wf)
91
+ if err != nil {
92
+ return fmt.Errorf("repo %q ticket %s render mailboxes: %w", rp.Name, ticket.Key, err)
93
+ }
94
+ mailboxes, err := rp.TaskSystem.EnsureMailboxes(ctx, ticket.Ref(), wf.Name, specs)
90
95
  if err != nil {
91
96
  return fmt.Errorf("repo %q ticket %s ensure mailboxes: %w", rp.Name, ticket.Key, err)
92
97
  }
@@ -6,6 +6,7 @@ import (
6
6
  "path/filepath"
7
7
 
8
8
  "github.com/rajpopat27/relay-flow/internal/config"
9
+ "github.com/rajpopat27/relay-flow/internal/harness"
9
10
  "github.com/rajpopat27/relay-flow/internal/runner"
10
11
  "github.com/rajpopat27/relay-flow/internal/task"
11
12
  )
@@ -26,6 +27,7 @@ type ServiceConfig struct {
26
27
  ConfigPath string
27
28
  TaskPlugin string
28
29
  Runner runner.Runner
30
+ Harness harness.Harness
29
31
  Active ActiveRuns
30
32
  Workflows WorkflowRefs
31
33
  }
@@ -34,6 +36,7 @@ type Service struct {
34
36
  cfgPath string
35
37
  taskPlugin string
36
38
  runner runner.Runner
39
+ harness harness.Harness
37
40
  active ActiveRuns
38
41
  workflows WorkflowRefs
39
42
  reg *Registry
@@ -51,6 +54,7 @@ func NewServiceWithRegistry(cfg ServiceConfig, reg *Registry) *Service {
51
54
  cfgPath: cfg.ConfigPath,
52
55
  taskPlugin: cfg.TaskPlugin,
53
56
  runner: cfg.Runner,
57
+ harness: cfg.Harness,
54
58
  active: cfg.Active,
55
59
  workflows: cfg.Workflows,
56
60
  reg: reg,
@@ -141,6 +145,12 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
141
145
  if err != nil {
142
146
  return Info{}, fmt.Errorf("repo %q: task system connectivity: %w", input.Name, err)
143
147
  }
148
+ if s.harness == nil {
149
+ return Info{}, fmt.Errorf("repo %q: harness is not configured", input.Name)
150
+ }
151
+ if err := s.harness.SetupRepo(ctx, input.Path); err != nil {
152
+ return Info{}, fmt.Errorf("repo %q: harness setup: %w", input.Name, err)
153
+ }
144
154
  // Persist atomically, then update the in-memory registry.
145
155
  r := config.Repo{Path: input.Path, TaskConfig: input.TaskConfig}
146
156
  if cfg.Repos == nil {
@@ -8,6 +8,7 @@ import (
8
8
  "testing"
9
9
 
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/harness"
11
12
  "github.com/rajpopat27/relay-flow/internal/repo"
12
13
  "github.com/rajpopat27/relay-flow/internal/runner"
13
14
  "github.com/rajpopat27/relay-flow/internal/task"
@@ -48,6 +49,26 @@ func (f *fakeRunnerDiscovery) EnsureTerminal(context.Context, runner.Environment
48
49
  func (f *fakeRunnerDiscovery) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
49
50
  func (f *fakeRunnerDiscovery) CleanupRun(context.Context, runner.RunSpec) error { return nil }
50
51
 
52
+ type fakeHarness struct {
53
+ setupPaths []string
54
+ setupErr error
55
+ }
56
+
57
+ func (f *fakeHarness) SetupRepo(_ context.Context, path string) error {
58
+ f.setupPaths = append(f.setupPaths, path)
59
+ return f.setupErr
60
+ }
61
+ func (*fakeHarness) ValidateAgent(context.Context, string, string) error { return nil }
62
+ func (*fakeHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
63
+ return harness.Session{}, false, nil
64
+ }
65
+ func (*fakeHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
66
+ return "", nil
67
+ }
68
+ func (*fakeHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
69
+ return runner.Command{}, nil
70
+ }
71
+
51
72
  // fakeActiveRuns / fakeWorkflowRefs implement the consumer interfaces.
52
73
  type fakeActiveRuns struct{ repos map[string]bool }
53
74
 
@@ -102,6 +123,7 @@ type serviceFixture struct {
102
123
  cfgPath string
103
124
  active *fakeActiveRuns
104
125
  wfRefs *fakeWorkflowRefs
126
+ harness *fakeHarness
105
127
  }
106
128
 
107
129
  func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
@@ -118,14 +140,16 @@ func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
118
140
  }
119
141
  active := &fakeActiveRuns{repos: map[string]bool{}}
120
142
  wfRefs := &fakeWorkflowRefs{refs: map[string]bool{}}
143
+ hrn := &fakeHarness{}
121
144
  svc := repo.NewService(repo.ServiceConfig{
122
145
  ConfigPath: cfgPath,
123
146
  TaskPlugin: "testjira",
124
147
  Runner: rn,
148
+ Harness: hrn,
125
149
  Active: active,
126
150
  Workflows: wfRefs,
127
151
  })
128
- return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs}
152
+ return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs, harness: hrn}
129
153
  }
130
154
 
131
155
  func TestDiscoverDelegatesToRunner(t *testing.T) {
@@ -222,7 +246,7 @@ func TestRegisterValidatesTaskConnectivity(t *testing.T) {
222
246
  }
223
247
  svc := repo.NewService(repo.ServiceConfig{
224
248
  ConfigPath: cfgPath, TaskPlugin: "failingtask",
225
- Runner: &fakeRunnerDiscovery{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
249
+ Runner: &fakeRunnerDiscovery{}, Harness: &fakeHarness{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
226
250
  })
227
251
  _, err := svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY"}})
228
252
  if err == nil {
@@ -256,6 +280,31 @@ func TestRegisterAtomicallyPersists(t *testing.T) {
256
280
  }
257
281
  }
258
282
 
283
+ func TestRegisterSetsUpHarnessRepo(t *testing.T) {
284
+ fx := newServiceFixture(t, &fakeRunnerDiscovery{})
285
+ if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
286
+ t.Fatal(err)
287
+ }
288
+ if len(fx.harness.setupPaths) != 1 || fx.harness.setupPaths[0] != "/srv/payments" {
289
+ t.Fatalf("SetupRepo paths = %v", fx.harness.setupPaths)
290
+ }
291
+ }
292
+
293
+ func TestRegisterHarnessSetupFailureDoesNotPersist(t *testing.T) {
294
+ fx := newServiceFixture(t, &fakeRunnerDiscovery{})
295
+ fx.harness.setupErr = errors.New("setup failed")
296
+ if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err == nil {
297
+ t.Fatal("registration succeeded despite harness setup failure")
298
+ }
299
+ cfg, err := config.LoadMachine(fx.cfgPath)
300
+ if err != nil {
301
+ t.Fatal(err)
302
+ }
303
+ if len(cfg.Repos) != 0 {
304
+ t.Fatalf("repos persisted after setup failure: %v", cfg.Repos)
305
+ }
306
+ }
307
+
259
308
  func TestRemoveStopsPollerAndRemoves(t *testing.T) {
260
309
  fx := newServiceFixture(t, &fakeRunnerDiscovery{})
261
310
  if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
@@ -63,10 +63,12 @@ type NodeWork struct {
63
63
  type CommentWork struct {
64
64
  // RunID is the enclosing durable run; the comment activity uses it to
65
65
  // attribute its 9.3 transition log lines (ticket/runID/node attrs).
66
- RunID ID
67
- Item task.Target
68
- Body string
69
- Marker string
66
+ RunID ID
67
+ Item task.Target
68
+ Body string
69
+ TextKind task.TextKind
70
+ TextData task.TextData
71
+ Marker string
70
72
  }
71
73
 
72
74
  // RetryStatus describes the currently pending durable activity retry without