relay-flow 0.2.1-alpha → 0.2.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.
Files changed (83) hide show
  1. package/README.md +193 -25
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +79 -1
  4. package/cmd/relay-flow/main.go +50 -1
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +16 -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 +124 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +20 -0
  16. package/internal/execution/goworkflows/engine_test.go +33 -15
  17. package/internal/execution/goworkflows/fakes_test.go +84 -10
  18. package/internal/execution/goworkflows/interpreter.go +84 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  21. package/internal/execution/goworkflows/projection.go +52 -11
  22. package/internal/execution/goworkflows/recovery_test.go +131 -2
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/harness/contract_test.go +10 -0
  25. package/internal/harness/factory.go +25 -3
  26. package/internal/harness/harness.go +30 -4
  27. package/internal/harness/opencode/opencode.go +125 -10
  28. package/internal/harness/opencode/opencode_test.go +238 -0
  29. package/internal/harness/opencode/repo_setup.go +401 -0
  30. package/internal/harness/plugin_selection_test.go +5 -5
  31. package/internal/identity/identity.go +28 -1
  32. package/internal/identity/identity_test.go +40 -0
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/service.go +10 -0
  35. package/internal/repo/service_test.go +51 -2
  36. package/internal/run/manager.go +193 -25
  37. package/internal/run/run.go +30 -10
  38. package/internal/run/run_manager_test.go +100 -0
  39. package/internal/server/api_test.go +54 -0
  40. package/internal/server/client.go +11 -0
  41. package/internal/server/fixture_test.go +18 -0
  42. package/internal/server/server.go +16 -0
  43. package/internal/task/beads/bdcli/bdcli.go +323 -0
  44. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  45. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  46. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  47. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  48. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  49. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  50. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  51. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  52. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  56. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  57. package/internal/task/beads/beads.go +856 -0
  58. package/internal/task/beads/beads_test.go +609 -0
  59. package/internal/task/beads/comments_test.go +242 -0
  60. package/internal/task/beads/config_compatibility_test.go +163 -0
  61. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  62. package/internal/task/beads/repo_composition_test.go +232 -0
  63. package/internal/task/beads/runtime_config_test.go +81 -0
  64. package/internal/task/beads/status_compatibility_test.go +276 -0
  65. package/internal/task/beads/status_test.go +257 -0
  66. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  67. package/internal/task/beads/validation_test.go +110 -0
  68. package/internal/task/contract_test.go +10 -0
  69. package/internal/task/factory.go +37 -4
  70. package/internal/task/jira/auth.go +27 -1
  71. package/internal/task/jira/auth_test.go +54 -1
  72. package/internal/task/jira/filters_test.go +60 -0
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +187 -29
  75. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  76. package/internal/task/jira/rest/adf.go +119 -82
  77. package/internal/task/jira/rest/adf_test.go +60 -0
  78. package/internal/task/jira/rest/client_test.go +1 -1
  79. package/internal/task/jira/templates_test.go +118 -0
  80. package/internal/task/jira/transition_defaults_test.go +47 -2
  81. package/internal/task/task.go +40 -0
  82. package/internal/workflow/report_test.go +45 -0
  83. package/package.json +1 -1
@@ -0,0 +1,401 @@
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.3-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
+ if err := ensurePluginConfig(path, mode, ""); err != nil {
51
+ return err
52
+ }
53
+
54
+ tuiPath, tuiMode, err := openCodeTUIConfigPath(repoPath)
55
+ if err != nil {
56
+ return err
57
+ }
58
+ if err := os.MkdirAll(filepath.Dir(tuiPath), 0o755); err != nil {
59
+ return fmt.Errorf("opencode: create TUI config directory: %w", err)
60
+ }
61
+ if err := ensurePluginConfig(tuiPath, tuiMode, "https://opencode.ai/tui.json"); err != nil {
62
+ return fmt.Errorf("opencode: setup TUI config: %w", err)
63
+ }
64
+ return nil
65
+ }
66
+
67
+ func ensurePluginConfig(path string, mode os.FileMode, schema string) error {
68
+ data, err := os.ReadFile(path)
69
+ if err != nil {
70
+ if !os.IsNotExist(err) {
71
+ return fmt.Errorf("opencode: read %s: %w", path, err)
72
+ }
73
+ if schema == "" {
74
+ data = []byte("{\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
75
+ } else {
76
+ data = []byte("{\n \"$schema\": \"" + schema + "\",\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
77
+ }
78
+ } else {
79
+ data, err = updateOpenCodeConfig(data)
80
+ if err != nil {
81
+ return fmt.Errorf("opencode: update %s: %w", path, err)
82
+ }
83
+ }
84
+ if existing, readErr := os.ReadFile(path); readErr == nil && string(existing) == string(data) {
85
+ return nil
86
+ }
87
+ if err := config.WriteAtomic(path, data, mode); err != nil {
88
+ return fmt.Errorf("opencode: write config: %w", err)
89
+ }
90
+ return nil
91
+ }
92
+
93
+ func openCodeConfigPath(repoPath string) (string, os.FileMode, error) {
94
+ jsonPath := filepath.Join(repoPath, "opencode.json")
95
+ jsoncPath := filepath.Join(repoPath, "opencode.jsonc")
96
+ for _, path := range []string{jsonPath, jsoncPath} {
97
+ info, err := os.Stat(path)
98
+ if err == nil {
99
+ if !info.Mode().IsRegular() {
100
+ return "", 0, fmt.Errorf("opencode: config %s is not a regular file", path)
101
+ }
102
+ return path, info.Mode().Perm(), nil
103
+ }
104
+ if !os.IsNotExist(err) {
105
+ return "", 0, fmt.Errorf("opencode: inspect %s: %w", path, err)
106
+ }
107
+ }
108
+ return jsonPath, 0o644, nil
109
+ }
110
+
111
+ func openCodeTUIConfigPath(repoPath string) (string, os.FileMode, error) {
112
+ dir := filepath.Join(repoPath, ".opencode")
113
+ for _, name := range []string{"tui.json", "tui.jsonc"} {
114
+ path := filepath.Join(dir, name)
115
+ info, err := os.Stat(path)
116
+ if err == nil {
117
+ if !info.Mode().IsRegular() {
118
+ return "", 0, fmt.Errorf("opencode: TUI config %s is not a regular file", path)
119
+ }
120
+ return path, info.Mode().Perm(), nil
121
+ }
122
+ if !os.IsNotExist(err) {
123
+ return "", 0, fmt.Errorf("opencode: inspect TUI config %s: %w", path, err)
124
+ }
125
+ }
126
+ return filepath.Join(dir, "tui.json"), 0o644, nil
127
+ }
128
+
129
+ func updateOpenCodeConfig(data []byte) ([]byte, error) {
130
+ tokens, err := tokenizeJSONC(data)
131
+ if err != nil {
132
+ return nil, err
133
+ }
134
+ p := jsoncParser{tokens: tokens}
135
+ root, err := p.parseValue()
136
+ if err != nil {
137
+ return nil, err
138
+ }
139
+ if p.index != len(tokens) || root.kind != '{' {
140
+ return nil, fmt.Errorf("config root must be one JSON object")
141
+ }
142
+
143
+ var plugins *jsoncValue
144
+ for i := range root.members {
145
+ if root.members[i].name == "plugin" {
146
+ if plugins != nil {
147
+ return nil, fmt.Errorf("duplicate plugin property")
148
+ }
149
+ plugins = &root.members[i].value
150
+ }
151
+ }
152
+ quoted, _ := json.Marshal(relayFlowPlugin)
153
+ if plugins == nil {
154
+ return addPluginProperty(data, tokens, root, string(quoted)), nil
155
+ }
156
+ if plugins.kind != '[' {
157
+ return nil, fmt.Errorf("plugin property must be an array")
158
+ }
159
+
160
+ first := -1
161
+ duplicates := make([]int, 0)
162
+ for i, element := range plugins.elements {
163
+ tok := tokens[element.startToken]
164
+ if element.kind != 's' {
165
+ return nil, fmt.Errorf("plugin array element %d must be a string", i)
166
+ }
167
+ if tok.value == "relay-flow-plugin" || strings.HasPrefix(tok.value, "relay-flow-plugin@") {
168
+ if first < 0 {
169
+ first = i
170
+ } else {
171
+ duplicates = append(duplicates, i)
172
+ }
173
+ }
174
+ }
175
+
176
+ edits := make([]textEdit, 0, len(duplicates)+1)
177
+ if first < 0 {
178
+ edits = append(edits, addArrayElementEdit(tokens, *plugins, string(quoted)))
179
+ } else {
180
+ tok := tokens[plugins.elements[first].startToken]
181
+ edits = append(edits, textEdit{start: tok.start, end: tok.end, text: string(quoted)})
182
+ for _, index := range duplicates {
183
+ element := plugins.elements[index]
184
+ comma := commaBefore(tokens, plugins.elements[index-1].endToken, element.startToken)
185
+ edits = append(edits,
186
+ textEdit{start: tokens[comma].start, end: tokens[comma].end},
187
+ textEdit{start: tokens[element.startToken].start, end: tokens[element.endToken].end},
188
+ )
189
+ }
190
+ }
191
+ return applyTextEdits(data, edits), nil
192
+ }
193
+
194
+ func addPluginProperty(data []byte, tokens []jsoncToken, root jsoncValue, quoted string) []byte {
195
+ closeAt := tokens[root.closeToken].start
196
+ edits := []textEdit{{
197
+ start: closeAt,
198
+ end: closeAt,
199
+ text: "\n \"plugin\": [" + quoted + "]\n",
200
+ }}
201
+ if len(root.members) > 0 {
202
+ last := root.members[len(root.members)-1].value.endToken
203
+ if commaBefore(tokens, last, root.closeToken) < 0 {
204
+ at := tokens[last].end
205
+ edits = append(edits, textEdit{start: at, end: at, text: ","})
206
+ }
207
+ }
208
+ return applyTextEdits(data, edits)
209
+ }
210
+
211
+ func addArrayElementEdit(tokens []jsoncToken, array jsoncValue, quoted string) textEdit {
212
+ if len(array.elements) == 0 {
213
+ at := tokens[array.closeToken].start
214
+ return textEdit{start: at, end: at, text: quoted}
215
+ }
216
+ last := array.elements[len(array.elements)-1]
217
+ if comma := commaBefore(tokens, last.endToken, array.closeToken); comma >= 0 {
218
+ at := tokens[comma].end
219
+ return textEdit{start: at, end: at, text: " " + quoted}
220
+ }
221
+ at := tokens[last.endToken].end
222
+ return textEdit{start: at, end: at, text: ", " + quoted}
223
+ }
224
+
225
+ func commaBefore(tokens []jsoncToken, after, before int) int {
226
+ for i := after + 1; i < before; i++ {
227
+ if tokens[i].kind == ',' {
228
+ return i
229
+ }
230
+ }
231
+ return -1
232
+ }
233
+
234
+ func applyTextEdits(data []byte, edits []textEdit) []byte {
235
+ for i := 0; i < len(edits); i++ {
236
+ for j := i + 1; j < len(edits); j++ {
237
+ if edits[j].start > edits[i].start {
238
+ edits[i], edits[j] = edits[j], edits[i]
239
+ }
240
+ }
241
+ }
242
+ out := append([]byte(nil), data...)
243
+ for _, edit := range edits {
244
+ out = append(out[:edit.start], append([]byte(edit.text), out[edit.end:]...)...)
245
+ }
246
+ return out
247
+ }
248
+
249
+ func tokenizeJSONC(data []byte) ([]jsoncToken, error) {
250
+ tokens := make([]jsoncToken, 0)
251
+ for i := 0; i < len(data); {
252
+ switch data[i] {
253
+ case ' ', '\t', '\r', '\n':
254
+ i++
255
+ continue
256
+ case '/':
257
+ if i+1 >= len(data) {
258
+ return nil, fmt.Errorf("unexpected slash at byte %d", i)
259
+ }
260
+ if data[i+1] == '/' {
261
+ i += 2
262
+ for i < len(data) && data[i] != '\n' {
263
+ i++
264
+ }
265
+ continue
266
+ }
267
+ if data[i+1] == '*' {
268
+ start := i
269
+ i += 2
270
+ for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') {
271
+ i++
272
+ }
273
+ if i+1 >= len(data) {
274
+ return nil, fmt.Errorf("unterminated comment at byte %d", start)
275
+ }
276
+ i += 2
277
+ continue
278
+ }
279
+ return nil, fmt.Errorf("unexpected slash at byte %d", i)
280
+ case '{', '}', '[', ']', ':', ',':
281
+ tokens = append(tokens, jsoncToken{kind: data[i], start: i, end: i + 1})
282
+ i++
283
+ case '"':
284
+ start := i
285
+ i++
286
+ for i < len(data) {
287
+ if data[i] == '\\' {
288
+ i += 2
289
+ continue
290
+ }
291
+ if data[i] == '"' {
292
+ i++
293
+ break
294
+ }
295
+ i++
296
+ }
297
+ if i > len(data) || data[i-1] != '"' {
298
+ return nil, fmt.Errorf("unterminated string at byte %d", start)
299
+ }
300
+ var value string
301
+ if err := json.Unmarshal(data[start:i], &value); err != nil {
302
+ return nil, fmt.Errorf("invalid string at byte %d: %w", start, err)
303
+ }
304
+ tokens = append(tokens, jsoncToken{kind: 's', start: start, end: i, value: value})
305
+ default:
306
+ start := i
307
+ for i < len(data) && !strings.ContainsRune(" \t\r\n{}[]:,/", rune(data[i])) {
308
+ i++
309
+ }
310
+ if start == i || !json.Valid(data[start:i]) {
311
+ return nil, fmt.Errorf("invalid value at byte %d", start)
312
+ }
313
+ tokens = append(tokens, jsoncToken{kind: 'v', start: start, end: i})
314
+ }
315
+ }
316
+ return tokens, nil
317
+ }
318
+
319
+ func (p *jsoncParser) parseValue() (jsoncValue, error) {
320
+ if p.index >= len(p.tokens) {
321
+ return jsoncValue{}, fmt.Errorf("expected value")
322
+ }
323
+ start := p.index
324
+ tok := p.tokens[p.index]
325
+ switch tok.kind {
326
+ case 's', 'v':
327
+ p.index++
328
+ return jsoncValue{kind: tok.kind, startToken: start, endToken: start}, nil
329
+ case '{':
330
+ return p.parseObject()
331
+ case '[':
332
+ return p.parseArray()
333
+ default:
334
+ return jsoncValue{}, fmt.Errorf("unexpected token %q at byte %d", tok.kind, tok.start)
335
+ }
336
+ }
337
+
338
+ func (p *jsoncParser) parseObject() (jsoncValue, error) {
339
+ value := jsoncValue{kind: '{', startToken: p.index}
340
+ p.index++
341
+ for {
342
+ if p.index >= len(p.tokens) {
343
+ return jsoncValue{}, fmt.Errorf("unterminated object")
344
+ }
345
+ if p.tokens[p.index].kind == '}' {
346
+ value.closeToken = p.index
347
+ value.endToken = p.index
348
+ p.index++
349
+ return value, nil
350
+ }
351
+ key := p.tokens[p.index]
352
+ if key.kind != 's' {
353
+ return jsoncValue{}, fmt.Errorf("object key at byte %d must be a string", key.start)
354
+ }
355
+ p.index++
356
+ if p.index >= len(p.tokens) || p.tokens[p.index].kind != ':' {
357
+ return jsoncValue{}, fmt.Errorf("object key %q is missing a colon", key.value)
358
+ }
359
+ p.index++
360
+ memberValue, err := p.parseValue()
361
+ if err != nil {
362
+ return jsoncValue{}, err
363
+ }
364
+ value.members = append(value.members, jsoncMember{name: key.value, value: memberValue})
365
+ if p.index < len(p.tokens) && p.tokens[p.index].kind == ',' {
366
+ p.index++
367
+ continue
368
+ }
369
+ if p.index >= len(p.tokens) || p.tokens[p.index].kind != '}' {
370
+ return jsoncValue{}, fmt.Errorf("object member %q is missing a comma", key.value)
371
+ }
372
+ }
373
+ }
374
+
375
+ func (p *jsoncParser) parseArray() (jsoncValue, error) {
376
+ value := jsoncValue{kind: '[', startToken: p.index}
377
+ p.index++
378
+ for {
379
+ if p.index >= len(p.tokens) {
380
+ return jsoncValue{}, fmt.Errorf("unterminated array")
381
+ }
382
+ if p.tokens[p.index].kind == ']' {
383
+ value.closeToken = p.index
384
+ value.endToken = p.index
385
+ p.index++
386
+ return value, nil
387
+ }
388
+ element, err := p.parseValue()
389
+ if err != nil {
390
+ return jsoncValue{}, err
391
+ }
392
+ value.elements = append(value.elements, element)
393
+ if p.index < len(p.tokens) && p.tokens[p.index].kind == ',' {
394
+ p.index++
395
+ continue
396
+ }
397
+ if p.index >= len(p.tokens) || p.tokens[p.index].kind != ']' {
398
+ return jsoncValue{}, fmt.Errorf("array element is missing a comma")
399
+ }
400
+ }
401
+ }
@@ -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 },
@@ -6,12 +6,18 @@ import (
6
6
  "crypto/rand"
7
7
  "encoding/hex"
8
8
  "net/url"
9
+ "strconv"
9
10
  "strings"
10
11
  )
11
12
 
12
- // RunID identifies one durable run. Opaque to consumers.
13
+ // RunID identifies one durable execution attempt. Opaque to consumers.
13
14
  type RunID string
14
15
 
16
+ // AttemptID identifies an execution generation for one logical
17
+ // repo/workflow/ticket run. Attempt numbers are durably allocated by the Run
18
+ // Manager and are intentionally simple numeric values.
19
+ type AttemptID uint64
20
+
15
21
  // NodeVisitID identifies one entry into a workflow node. Opaque to consumers.
16
22
  type NodeVisitID string
17
23
 
@@ -25,6 +31,27 @@ func NewRunID(repo, workflow, ticket string) RunID {
25
31
  }, "/"))
26
32
  }
27
33
 
34
+ // NewAttemptRunID derives an execution ID that is fenced from the stable
35
+ // logical run ID. Attempt 1 retains the original deterministic ID for the
36
+ // first execution; explicit restarts use numeric attempt suffixes.
37
+ func NewAttemptRunID(logical RunID, attempt AttemptID) RunID {
38
+ if attempt <= 1 {
39
+ return logical
40
+ }
41
+ return RunID(string(logical) + "~attempt~" + strconv.FormatUint(uint64(attempt), 10))
42
+ }
43
+
44
+ // LogicalRunID returns the stable logical ID embedded in an execution ID.
45
+ // First attempts are already logical IDs; explicit attempts use the
46
+ // `~attempt~<number>` suffix generated by NewAttemptRunID.
47
+ func LogicalRunID(execution RunID) RunID {
48
+ const marker = "~attempt~"
49
+ if index := strings.LastIndex(string(execution), marker); index >= 0 {
50
+ return RunID(string(execution)[:index])
51
+ }
52
+ return execution
53
+ }
54
+
28
55
  // NewNodeVisitID returns a fresh random node-visit ID. Generation happens
29
56
  // once per node entry as a durable replay-safe side effect; this function
30
57
  // only produces the random value.
@@ -0,0 +1,40 @@
1
+ package identity_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/identity"
7
+ )
8
+
9
+ func TestAttemptRunIDUsesNumericAttemptSuffix(t *testing.T) {
10
+ logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
11
+
12
+ if got := identity.NewAttemptRunID(logical, 1); got != logical {
13
+ t.Fatalf("initial attempt ID = %q, want logical ID %q", got, logical)
14
+ }
15
+ if got := identity.NewAttemptRunID(logical, 2); got != logical+"~attempt~2" {
16
+ t.Fatalf("restart attempt ID = %q, want numeric suffix", got)
17
+ }
18
+ if got := identity.NewAttemptRunID(logical, 3); got == identity.NewAttemptRunID(logical, 2) {
19
+ t.Fatalf("attempt IDs are not fenced: attempt 2 and 3 both use %q", got)
20
+ }
21
+ }
22
+
23
+ func TestAttemptIDsAreNumeric(t *testing.T) {
24
+ var first identity.AttemptID = 1
25
+ var second identity.AttemptID = 2
26
+ if first != 1 || second != 2 {
27
+ t.Fatalf("attempt IDs are not numeric: %d, %d", first, second)
28
+ }
29
+ }
30
+
31
+ func TestLogicalRunIDStripsAttemptSuffix(t *testing.T) {
32
+ logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
33
+ execution := identity.NewAttemptRunID(logical, 4)
34
+ if got := identity.LogicalRunID(execution); got != logical {
35
+ t.Fatalf("logical ID = %q, want %q", got, logical)
36
+ }
37
+ if got := identity.LogicalRunID(logical); got != logical {
38
+ t.Fatalf("first-attempt logical ID = %q, want %q", got, logical)
39
+ }
40
+ }
@@ -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 {