relay-flow 0.2.0-alpha → 0.2.2-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -1,13 +1,21 @@
1
1
  package opencode_test
2
2
 
3
3
  import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
4
7
  "reflect"
8
+ "strings"
5
9
  "testing"
6
10
 
11
+ "github.com/rajpopat27/relay-flow/internal/config"
7
12
  "github.com/rajpopat27/relay-flow/internal/harness"
8
13
  "github.com/rajpopat27/relay-flow/internal/harness/opencode"
14
+ "github.com/rajpopat27/relay-flow/internal/workflow"
9
15
  )
10
16
 
17
+ const configuredPlugin = "relay-flow-plugin@0.2.2-alpha"
18
+
11
19
  func TestBuildCommandArgv(t *testing.T) {
12
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
13
21
  tests := []struct {
@@ -48,3 +56,178 @@ func TestBuildCommandArgv(t *testing.T) {
48
56
  })
49
57
  }
50
58
  }
59
+
60
+ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
61
+ raw := config.RawValues{
62
+ "initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
63
+ "feedback": "feedback {{mailbox}}",
64
+ "hitl": "hitl {{node}}",
65
+ }
66
+ h, err := harness.New("opencode", raw)
67
+ if err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ data := harness.PromptData{
71
+ TaskSystem: "linear", Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments",
72
+ Node: "review", NodeType: workflow.NodeHITL, Agent: "build", NodeDescription: "Review it.",
73
+ NextSteps: "end (when: approved)", Mailbox: "PAY-234",
74
+ }
75
+ nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
76
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
77
+ if err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nhitl review\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
81
+ if initial != wantInitial {
82
+ t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
83
+ }
84
+ feedback, err := h.RenderPrompt(harness.PromptFeedback, data, nudge)
85
+ if err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ if want := "feedback PAY-234\n\nhitl review\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
89
+ t.Fatalf("feedback prompt = %q, want %q", feedback, want)
90
+ }
91
+ }
92
+
93
+ func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
94
+ _, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
95
+ if err == nil || !strings.Contains(err.Error(), "unknown template variable {{unknown}}") {
96
+ t.Fatalf("harness.New error = %v", err)
97
+ }
98
+ }
99
+
100
+ func TestSetupRepoCreatesOpenCodeJSON(t *testing.T) {
101
+ dir := t.TempDir()
102
+ setupTwice(t, dir)
103
+ path := filepath.Join(dir, "opencode.json")
104
+ data, err := os.ReadFile(path)
105
+ if err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ var cfg struct {
109
+ Plugin []string `json:"plugin"`
110
+ }
111
+ if err := json.Unmarshal(data, &cfg); err != nil {
112
+ t.Fatalf("created config is invalid JSON: %v\n%s", err, data)
113
+ }
114
+ if !reflect.DeepEqual(cfg.Plugin, []string{configuredPlugin}) {
115
+ t.Fatalf("plugin = %v", cfg.Plugin)
116
+ }
117
+ }
118
+
119
+ func TestSetupRepoUpdatesExistingJSONAndPreservesContent(t *testing.T) {
120
+ dir := t.TempDir()
121
+ path := filepath.Join(dir, "opencode.json")
122
+ original := "{\n \"theme\": \"catppuccin\",\n \"plugin\": [\"keep-me\", \"relay-flow-plugin@0.1.0\"],\n \"nested\": {\"enabled\": true}\n}\n"
123
+ if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ setupTwice(t, dir)
127
+ data, err := os.ReadFile(path)
128
+ if err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ var cfg struct {
132
+ Theme string `json:"theme"`
133
+ Plugin []string `json:"plugin"`
134
+ Nested map[string]bool `json:"nested"`
135
+ }
136
+ if err := json.Unmarshal(data, &cfg); err != nil {
137
+ t.Fatalf("updated config is invalid JSON: %v\n%s", err, data)
138
+ }
139
+ if cfg.Theme != "catppuccin" || !cfg.Nested["enabled"] {
140
+ t.Fatalf("existing config changed: %+v", cfg)
141
+ }
142
+ if !reflect.DeepEqual(cfg.Plugin, []string{"keep-me", configuredPlugin}) {
143
+ t.Fatalf("plugin = %v", cfg.Plugin)
144
+ }
145
+ info, err := os.Stat(path)
146
+ if err != nil {
147
+ t.Fatal(err)
148
+ }
149
+ if info.Mode().Perm() != 0o640 {
150
+ t.Fatalf("mode = %o, want 640", info.Mode().Perm())
151
+ }
152
+ }
153
+
154
+ func TestSetupRepoUpdatesJSONCOnceAndPreservesComments(t *testing.T) {
155
+ dir := t.TempDir()
156
+ path := filepath.Join(dir, "opencode.jsonc")
157
+ original := `{
158
+ // theme stays
159
+ "theme": "catppuccin",
160
+ "plugin": [
161
+ "keep-me", // existing plugin stays
162
+ "relay-flow-plugin",
163
+ /* duplicate old relay-flow entry */
164
+ "relay-flow-plugin@0.1.0",
165
+ ],
166
+ "nested": {"enabled": true}, // trailing comment stays
167
+ }
168
+ `
169
+ if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
170
+ t.Fatal(err)
171
+ }
172
+ setupTwice(t, dir)
173
+ data, err := os.ReadFile(path)
174
+ if err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ text := string(data)
178
+ for _, preserved := range []string{"// theme stays", "// existing plugin stays", "/* duplicate old relay-flow entry */", "// trailing comment stays", "\"keep-me\"", "\"nested\": {\"enabled\": true}"} {
179
+ if !strings.Contains(text, preserved) {
180
+ t.Fatalf("config lost %q:\n%s", preserved, text)
181
+ }
182
+ }
183
+ if strings.Count(text, configuredPlugin) != 1 || strings.Contains(text, "relay-flow-plugin@0.1.0") {
184
+ t.Fatalf("relay-flow plugin not updated exactly once:\n%s", text)
185
+ }
186
+ if _, err := os.Stat(filepath.Join(dir, "opencode.json")); !os.IsNotExist(err) {
187
+ t.Fatalf("unexpected opencode.json created: %v", err)
188
+ }
189
+ }
190
+
191
+ func TestSetupRepoAddsPluginPropertyToJSONCWithComments(t *testing.T) {
192
+ dir := t.TempDir()
193
+ path := filepath.Join(dir, "opencode.jsonc")
194
+ original := "{\n \"theme\": \"catppuccin\" // keep at end\n}\n"
195
+ if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ setupTwice(t, dir)
199
+ data, err := os.ReadFile(path)
200
+ if err != nil {
201
+ t.Fatal(err)
202
+ }
203
+ text := string(data)
204
+ if !strings.Contains(text, "// keep at end") || strings.Count(text, configuredPlugin) != 1 {
205
+ t.Fatalf("config not preserved and updated:\n%s", text)
206
+ }
207
+ }
208
+
209
+ func setupTwice(t *testing.T, dir string) {
210
+ t.Helper()
211
+ h := opencode.New()
212
+ if err := h.SetupRepo(t.Context(), dir); err != nil {
213
+ t.Fatalf("first SetupRepo: %v", err)
214
+ }
215
+ path := filepath.Join(dir, "opencode.json")
216
+ if _, err := os.Stat(path); os.IsNotExist(err) {
217
+ path = filepath.Join(dir, "opencode.jsonc")
218
+ }
219
+ first, err := os.ReadFile(path)
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ if err := h.SetupRepo(t.Context(), dir); err != nil {
224
+ t.Fatalf("second SetupRepo: %v", err)
225
+ }
226
+ second, err := os.ReadFile(path)
227
+ if err != nil {
228
+ t.Fatal(err)
229
+ }
230
+ if !reflect.DeepEqual(first, second) {
231
+ t.Fatalf("SetupRepo is not idempotent\nfirst:\n%s\nsecond:\n%s", first, second)
232
+ }
233
+ }
@@ -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 },
@@ -10,14 +10,15 @@ import (
10
10
 
11
11
  // Paths is the fixed ~/.relay-flow layout. All process artifacts stay under Root.
12
12
  type Paths struct {
13
- Root string
14
- Config string
15
- Workflows string
16
- Database string
17
- Socket string
18
- Lock string
19
- ServerLog string
20
- PluginLog string
13
+ Root string
14
+ Config string
15
+ Credentials string
16
+ Workflows string
17
+ Database string
18
+ Socket string
19
+ Lock string
20
+ ServerLog string
21
+ PluginLog string
21
22
  }
22
23
 
23
24
  // ForUserHome returns the Paths rooted at the current user's home directory.
@@ -28,14 +29,15 @@ func ForUserHome() (Paths, error) {
28
29
  }
29
30
  root := filepath.Join(home, ".relay-flow")
30
31
  return Paths{
31
- Root: root,
32
- Config: filepath.Join(root, "config.yaml"),
33
- Workflows: filepath.Join(root, "workflows"),
34
- Database: filepath.Join(root, "state.db"),
35
- Socket: filepath.Join(root, "server.sock"),
36
- Lock: filepath.Join(root, "server.lock"),
37
- ServerLog: filepath.Join(root, "server.log"),
38
- PluginLog: filepath.Join(root, "plugin.log"),
32
+ Root: root,
33
+ Config: filepath.Join(root, "config.yaml"),
34
+ Credentials: filepath.Join(root, "credentials.yaml"),
35
+ Workflows: filepath.Join(root, "workflows"),
36
+ Database: filepath.Join(root, "state.db"),
37
+ Socket: filepath.Join(root, "server.sock"),
38
+ Lock: filepath.Join(root, "server.lock"),
39
+ ServerLog: filepath.Join(root, "server.log"),
40
+ PluginLog: filepath.Join(root, "plugin.log"),
39
41
  }, nil
40
42
  }
41
43
 
@@ -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
  }