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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- 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 +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -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 +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -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/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 +840 -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 +233 -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 +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /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 },
|
package/internal/paths/paths.go
CHANGED
|
@@ -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
|
|
14
|
-
Config
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
32
|
-
Config:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
}
|