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.
- package/README.md +193 -25
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +79 -1
- package/cmd/relay-flow/main.go +50 -1
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +16 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +124 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +84 -10
- package/internal/execution/goworkflows/interpreter.go +84 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +131 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +238 -0
- package/internal/harness/opencode/repo_setup.go +401 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +30 -10
- package/internal/run/run_manager_test.go +100 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/empty.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +856 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +276 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +187 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +47 -2
- package/internal/task/task.go +40 -0
- package/internal/workflow/report_test.go +45 -0
- 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
|
|
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
|
|
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(
|
|
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.
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
}
|
package/internal/repo/service.go
CHANGED
|
@@ -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 {
|