relay-flow 0.0.1 → 0.2.0-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 +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
package config_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/paths"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// 3.29: machine config per specs/workflow-repo-management "Machine config
|
|
14
|
+
// stores global settings and registered repos" and "Global defaults are
|
|
15
|
+
// deterministic".
|
|
16
|
+
|
|
17
|
+
func TestLoadMachineDefaults(t *testing.T) {
|
|
18
|
+
dir := t.TempDir()
|
|
19
|
+
path := filepath.Join(dir, "config.yaml")
|
|
20
|
+
yaml := `
|
|
21
|
+
taskPlugin: jira
|
|
22
|
+
runnerPlugin: orca
|
|
23
|
+
harnessPlugin: opencode
|
|
24
|
+
`
|
|
25
|
+
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
cfg, err := config.LoadMachine(path)
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatalf("LoadMachine failed: %v", err)
|
|
31
|
+
}
|
|
32
|
+
if cfg.PollIntervalSeconds != 15 {
|
|
33
|
+
t.Fatalf("PollIntervalSeconds = %d, want default 15", cfg.PollIntervalSeconds)
|
|
34
|
+
}
|
|
35
|
+
if cfg.CompletedRunRetentionDays != 30 {
|
|
36
|
+
t.Fatalf("CompletedRunRetentionDays = %d, want default 30", cfg.CompletedRunRetentionDays)
|
|
37
|
+
}
|
|
38
|
+
if !cfg.KeepTerminalsAlive {
|
|
39
|
+
t.Fatal("KeepTerminalsAlive = false, want default true")
|
|
40
|
+
}
|
|
41
|
+
if !cfg.KeepSessionsAlive {
|
|
42
|
+
t.Fatal("KeepSessionsAlive = false, want default true")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestLoadMachineValidatesRuntimeKeepSettings(t *testing.T) {
|
|
47
|
+
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
48
|
+
invalid := "taskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\nkeepTerminalsAlive: true\nkeepSessionsAlive: false\n"
|
|
49
|
+
if err := os.WriteFile(path, []byte(invalid), 0600); err != nil {
|
|
50
|
+
t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
if _, err := config.LoadMachine(path); err == nil || !strings.Contains(err.Error(), "keepTerminalsAlive requires keepSessionsAlive") {
|
|
53
|
+
t.Fatalf("invalid keep settings error = %v", err)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
valid := "taskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\nkeepTerminalsAlive: false\nkeepSessionsAlive: false\n"
|
|
57
|
+
if err := os.WriteFile(path, []byte(valid), 0600); err != nil {
|
|
58
|
+
t.Fatal(err)
|
|
59
|
+
}
|
|
60
|
+
cfg, err := config.LoadMachine(path)
|
|
61
|
+
if err != nil || cfg.KeepSessionsAlive {
|
|
62
|
+
t.Fatalf("explicit session cleanup = %+v, %v", cfg, err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
keepSession := "taskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\nkeepTerminalsAlive: false\nkeepSessionsAlive: true\n"
|
|
66
|
+
if err := os.WriteFile(path, []byte(keepSession), 0600); err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
cfg, err = config.LoadMachine(path)
|
|
70
|
+
if err != nil || cfg.KeepTerminalsAlive || !cfg.KeepSessionsAlive {
|
|
71
|
+
t.Fatalf("explicit terminal cleanup with session retention = %+v, %v", cfg, err)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestLoadMachineRejectsNonPositiveGlobals(t *testing.T) {
|
|
76
|
+
for _, tc := range []struct{ name, yaml string }{
|
|
77
|
+
{"zero interval", "pollIntervalSeconds: 0\ntaskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\n"},
|
|
78
|
+
{"negative interval", "pollIntervalSeconds: -5\ntaskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\n"},
|
|
79
|
+
{"zero retention", "completedRunRetentionDays: 0\ntaskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\n"},
|
|
80
|
+
{"negative retention", "completedRunRetentionDays: -1\ntaskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\n"},
|
|
81
|
+
} {
|
|
82
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
83
|
+
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
84
|
+
if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil {
|
|
85
|
+
t.Fatal(err)
|
|
86
|
+
}
|
|
87
|
+
if _, err := config.LoadMachine(path); err == nil {
|
|
88
|
+
t.Fatal("non-positive global accepted")
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestLoadMachineRejectsUnknownFields(t *testing.T) {
|
|
95
|
+
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
96
|
+
yaml := `
|
|
97
|
+
taskPlugin: jira
|
|
98
|
+
runnerPlugin: orca
|
|
99
|
+
harnessPlugin: opencode
|
|
100
|
+
unknownField: x
|
|
101
|
+
repos:
|
|
102
|
+
payments:
|
|
103
|
+
path: /srv/payments
|
|
104
|
+
bogusRepoField: 1
|
|
105
|
+
`
|
|
106
|
+
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
|
|
107
|
+
t.Fatal(err)
|
|
108
|
+
}
|
|
109
|
+
if _, err := config.LoadMachine(path); err == nil {
|
|
110
|
+
t.Fatal("unknown root/repo fields accepted by strict decode")
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
|
115
|
+
dir := t.TempDir()
|
|
116
|
+
path := filepath.Join(dir, "config.yaml")
|
|
117
|
+
cfg := &config.Machine{
|
|
118
|
+
TaskPlugin: "jira",
|
|
119
|
+
RunnerPlugin: "orca",
|
|
120
|
+
HarnessPlugin: "opencode",
|
|
121
|
+
Repos: map[string]config.Repo{
|
|
122
|
+
"payments": {Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY"}},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
if err := config.SaveMachine(path, cfg); err != nil {
|
|
126
|
+
t.Fatalf("SaveMachine failed: %v", err)
|
|
127
|
+
}
|
|
128
|
+
// Owner-only permissions.
|
|
129
|
+
fi, err := os.Stat(path)
|
|
130
|
+
if err != nil {
|
|
131
|
+
t.Fatal(err)
|
|
132
|
+
}
|
|
133
|
+
if fi.Mode().Perm() != 0600 {
|
|
134
|
+
t.Fatalf("config mode = %o, want 0600", fi.Mode().Perm())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
loaded, err := config.LoadMachine(path)
|
|
138
|
+
if err != nil {
|
|
139
|
+
t.Fatalf("LoadMachine failed: %v", err)
|
|
140
|
+
}
|
|
141
|
+
if loaded.TaskPlugin != "jira" || loaded.Repos["payments"].Path != "/srv/payments" {
|
|
142
|
+
t.Fatalf("round trip mismatch: %+v", loaded)
|
|
143
|
+
}
|
|
144
|
+
if loaded.Repos["payments"].TaskConfig["project"] != "PAY" {
|
|
145
|
+
t.Fatalf("repo taskConfig lost: %+v", loaded.Repos["payments"].TaskConfig)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestRepoEntryHoldsOnlyPathAndTaskConfig(t *testing.T) {
|
|
150
|
+
// Repo entries hold only path and taskConfig; unknown keys are rejected
|
|
151
|
+
// by strict decode (covered above). Compile-time shape check.
|
|
152
|
+
var r config.Repo
|
|
153
|
+
_ = r.Path
|
|
154
|
+
_ = r.TaskConfig
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func TestFixedFilesystemLayoutAndPermissions(t *testing.T) {
|
|
158
|
+
// Fixed layout: every artifact lives under the relay-flow root with the
|
|
159
|
+
// documented filename. Assert the fixed names without coupling to the
|
|
160
|
+
// ambient user home.
|
|
161
|
+
p, err := paths.ForUserHome()
|
|
162
|
+
if err != nil {
|
|
163
|
+
t.Fatal(err)
|
|
164
|
+
}
|
|
165
|
+
for name, got := range map[string]string{
|
|
166
|
+
"Root": p.Root,
|
|
167
|
+
"Config": p.Config,
|
|
168
|
+
"Workflows": p.Workflows,
|
|
169
|
+
"Database": p.Database,
|
|
170
|
+
"Socket": p.Socket,
|
|
171
|
+
"Lock": p.Lock,
|
|
172
|
+
"ServerLog": p.ServerLog,
|
|
173
|
+
"PluginLog": p.PluginLog,
|
|
174
|
+
} {
|
|
175
|
+
if got == "" {
|
|
176
|
+
t.Fatalf("%s is empty", name)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Fixed filenames under the root.
|
|
180
|
+
if filepath.Base(p.Config) != "config.yaml" ||
|
|
181
|
+
filepath.Base(p.Database) != "state.db" ||
|
|
182
|
+
filepath.Base(p.Socket) != "server.sock" ||
|
|
183
|
+
filepath.Base(p.Lock) != "server.lock" ||
|
|
184
|
+
filepath.Base(p.ServerLog) != "server.log" ||
|
|
185
|
+
filepath.Base(p.PluginLog) != "plugin.log" ||
|
|
186
|
+
filepath.Base(p.Workflows) != "workflows" {
|
|
187
|
+
t.Fatalf("non-fixed layout: %+v", p)
|
|
188
|
+
}
|
|
189
|
+
// Everything is under the root.
|
|
190
|
+
for _, sub := range []string{p.Config, p.Database, p.Socket, p.Lock, p.ServerLog, p.PluginLog, p.Workflows} {
|
|
191
|
+
if !strings.HasPrefix(sub, p.Root) {
|
|
192
|
+
t.Fatalf("%q not under root %q", sub, p.Root)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Permissions on a controlled root: root 0700, logs 0600.
|
|
197
|
+
tmp := filepath.Join(t.TempDir(), ".relay-flow")
|
|
198
|
+
tp := p
|
|
199
|
+
tp.Root = tmp
|
|
200
|
+
tp.Config = filepath.Join(tmp, "config.yaml")
|
|
201
|
+
tp.Workflows = filepath.Join(tmp, "workflows")
|
|
202
|
+
tp.Database = filepath.Join(tmp, "state.db")
|
|
203
|
+
tp.Socket = filepath.Join(tmp, "server.sock")
|
|
204
|
+
tp.Lock = filepath.Join(tmp, "server.lock")
|
|
205
|
+
tp.ServerLog = filepath.Join(tmp, "server.log")
|
|
206
|
+
tp.PluginLog = filepath.Join(tmp, "plugin.log")
|
|
207
|
+
if err := paths.Ensure(tp); err != nil {
|
|
208
|
+
t.Fatal(err)
|
|
209
|
+
}
|
|
210
|
+
fi, err := os.Stat(tmp)
|
|
211
|
+
if err != nil || fi.Mode().Perm() != 0700 {
|
|
212
|
+
t.Fatalf("root mode = %v, want 0700", fi)
|
|
213
|
+
}
|
|
214
|
+
for _, lf := range []string{tp.ServerLog, tp.PluginLog} {
|
|
215
|
+
fi, err := os.Stat(lf)
|
|
216
|
+
if err != nil || fi.Mode().Perm() != 0600 {
|
|
217
|
+
t.Fatalf("log %s mode = %v, want 0600", lf, fi)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Config (0600) and workflow files (0644) via the atomic writer.
|
|
222
|
+
if err := config.SaveMachine(tp.Config, &config.Machine{TaskPlugin: "jira", RunnerPlugin: "orca", HarnessPlugin: "opencode"}); err != nil {
|
|
223
|
+
t.Fatal(err)
|
|
224
|
+
}
|
|
225
|
+
fi, _ = os.Stat(tp.Config)
|
|
226
|
+
if fi.Mode().Perm() != 0600 {
|
|
227
|
+
t.Fatalf("config mode = %o, want 0600", fi.Mode().Perm())
|
|
228
|
+
}
|
|
229
|
+
wfPath := filepath.Join(tp.Workflows, "basicFlow.yaml")
|
|
230
|
+
if err := config.WriteAtomic(wfPath, []byte("name: basicFlow\n"), 0644); err != nil {
|
|
231
|
+
t.Fatal(err)
|
|
232
|
+
}
|
|
233
|
+
fi, _ = os.Stat(wfPath)
|
|
234
|
+
if fi.Mode().Perm() != 0644 {
|
|
235
|
+
t.Fatalf("workflow mode = %o, want 0644", fi.Mode().Perm())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Database (state.db), socket (server.sock), and lock (server.lock) are
|
|
239
|
+
// mode 0600 and are created by their owning components: state.db by the
|
|
240
|
+
// engine (asserted in TestDatabaseFileIsOwnerOnly, internal/execution/
|
|
241
|
+
// goworkflows), server.sock by the server (asserted in
|
|
242
|
+
// TestSocketIsOwnerOnly, internal/server), and server.lock by the serve
|
|
243
|
+
// startup flock (section 5.5). This test pins the fixed paths and the
|
|
244
|
+
// config/log/workflow modes config owns.
|
|
245
|
+
_ = tp.Database
|
|
246
|
+
_ = tp.Lock
|
|
247
|
+
_ = tp.Socket
|
|
248
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package config_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"reflect"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// 3.4: Merge behavior per specs/workflow-definition "Task config merge
|
|
13
|
+
// behavior is deterministic": root -> repo -> workflow -> node precedence,
|
|
14
|
+
// recursive map merge, later scalar/list replaces, omitted keys inherit,
|
|
15
|
+
// explicit YAML null rejected.
|
|
16
|
+
|
|
17
|
+
func TestMergePrecedenceRootToNode(t *testing.T) {
|
|
18
|
+
root := config.RawValues{"assignee": "root-bot", "severity": "low"}
|
|
19
|
+
repo := config.RawValues{"severity": "medium"}
|
|
20
|
+
wf := config.RawValues{"severity": "high"}
|
|
21
|
+
node := config.RawValues{"severity": "critical"}
|
|
22
|
+
|
|
23
|
+
got := config.Merge(root, repo, wf, node)
|
|
24
|
+
if got["severity"] != "critical" {
|
|
25
|
+
t.Fatalf("severity = %v, want node value critical", got["severity"])
|
|
26
|
+
}
|
|
27
|
+
if got["assignee"] != "root-bot" {
|
|
28
|
+
t.Fatalf("assignee = %v, want inherited root-bot", got["assignee"])
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestMergeMapsRecursively(t *testing.T) {
|
|
33
|
+
root := config.RawValues{
|
|
34
|
+
"transitionTo": map[string]any{"parentStatus": "In Progress", "taskStatus": "To Do"},
|
|
35
|
+
}
|
|
36
|
+
node := config.RawValues{
|
|
37
|
+
"transitionTo": map[string]any{"taskStatus": "In Review"},
|
|
38
|
+
}
|
|
39
|
+
got := config.Merge(root, node)
|
|
40
|
+
tr, ok := got["transitionTo"].(map[string]any)
|
|
41
|
+
if !ok {
|
|
42
|
+
t.Fatalf("transitionTo not a map: %T", got["transitionTo"])
|
|
43
|
+
}
|
|
44
|
+
if tr["parentStatus"] != "In Progress" {
|
|
45
|
+
t.Fatalf("nested parentStatus = %v, want inherited In Progress", tr["parentStatus"])
|
|
46
|
+
}
|
|
47
|
+
if tr["taskStatus"] != "In Review" {
|
|
48
|
+
t.Fatalf("nested taskStatus = %v, want node override In Review", tr["taskStatus"])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestMergeListReplaces(t *testing.T) {
|
|
53
|
+
root := config.RawValues{"labels": []any{"a", "b"}}
|
|
54
|
+
wf := config.RawValues{"labels": []any{"c"}}
|
|
55
|
+
got := config.Merge(root, wf)
|
|
56
|
+
if !reflect.DeepEqual(got["labels"], []any{"c"}) {
|
|
57
|
+
t.Fatalf("labels = %v, want replacement [c] not append", got["labels"])
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func TestMergeScalarReplaces(t *testing.T) {
|
|
62
|
+
root := config.RawValues{"retries": "1"}
|
|
63
|
+
node := config.RawValues{"retries": "3"}
|
|
64
|
+
got := config.Merge(root, node)
|
|
65
|
+
if got["retries"] != "3" {
|
|
66
|
+
t.Fatalf("retries = %v, want 3", got["retries"])
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestMergeOmittedKeyInherits(t *testing.T) {
|
|
71
|
+
root := config.RawValues{"project": "PAY", "component": "api"}
|
|
72
|
+
repo := config.RawValues{"component": "web"}
|
|
73
|
+
got := config.Merge(root, repo)
|
|
74
|
+
if got["project"] != "PAY" {
|
|
75
|
+
t.Fatalf("project = %v, want inherited PAY", got["project"])
|
|
76
|
+
}
|
|
77
|
+
if got["component"] != "web" {
|
|
78
|
+
t.Fatalf("component = %v, want web", got["component"])
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestMergeRejectsExplicitNull(t *testing.T) {
|
|
83
|
+
// Explicit YAML null must be rejected by the documented validation path,
|
|
84
|
+
// not silently merged or dropped. DecodeStrict is the strict decode the
|
|
85
|
+
// adapters use; a null value is not a valid scalar/map/list.
|
|
86
|
+
node := config.RawValues{"assignee": nil}
|
|
87
|
+
var dst struct {
|
|
88
|
+
Assignee string `yaml:"assignee"`
|
|
89
|
+
}
|
|
90
|
+
if err := config.DecodeStrict(node, &dst); err == nil {
|
|
91
|
+
t.Fatal("explicit YAML null accepted by DecodeStrict; null must be rejected")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func TestMergeNullLayerRejectedAtValidation(t *testing.T) {
|
|
96
|
+
// Machine config containing an explicit null fails loading.
|
|
97
|
+
dir := t.TempDir()
|
|
98
|
+
path := filepath.Join(dir, "config.yaml")
|
|
99
|
+
yaml := "taskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\ntaskConfig:\n assignee: null\n"
|
|
100
|
+
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
|
|
101
|
+
t.Fatal(err)
|
|
102
|
+
}
|
|
103
|
+
if _, err := config.LoadMachine(path); err == nil {
|
|
104
|
+
t.Fatal("machine config with explicit null loaded; null must be rejected")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestMergeDoesNotMutateInputs(t *testing.T) {
|
|
109
|
+
root := config.RawValues{"m": map[string]any{"a": "1"}}
|
|
110
|
+
node := config.RawValues{"m": map[string]any{"b": "2"}}
|
|
111
|
+
_ = config.Merge(root, node)
|
|
112
|
+
if len(root["m"].(map[string]any)) != 1 {
|
|
113
|
+
t.Fatalf("root input mutated: %v", root)
|
|
114
|
+
}
|
|
115
|
+
if len(node["m"].(map[string]any)) != 1 {
|
|
116
|
+
t.Fatalf("node input mutated: %v", node)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package config
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io/fs"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
|
|
9
|
+
"github.com/google/renameio/v2"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// WriteAtomic creates a temporary sibling, writes and syncs it, sets
|
|
13
|
+
// permissions, renames it over the destination, and syncs the parent
|
|
14
|
+
// directory.
|
|
15
|
+
func WriteAtomic(path string, data []byte, mode fs.FileMode) error {
|
|
16
|
+
pf, err := renameio.NewPendingFile(path, renameio.WithStaticPermissions(mode.Perm()))
|
|
17
|
+
if err != nil {
|
|
18
|
+
return fmt.Errorf("create temp for %s: %w", path, err)
|
|
19
|
+
}
|
|
20
|
+
defer pf.Cleanup()
|
|
21
|
+
if _, err := pf.Write(data); err != nil {
|
|
22
|
+
return fmt.Errorf("write temp for %s: %w", path, err)
|
|
23
|
+
}
|
|
24
|
+
if err := pf.CloseAtomicallyReplace(); err != nil {
|
|
25
|
+
return fmt.Errorf("atomic replace %s: %w", path, err)
|
|
26
|
+
}
|
|
27
|
+
dir, err := os.Open(filepath.Dir(path))
|
|
28
|
+
if err != nil {
|
|
29
|
+
return fmt.Errorf("open parent dir of %s: %w", path, err)
|
|
30
|
+
}
|
|
31
|
+
defer dir.Close()
|
|
32
|
+
if err := dir.Sync(); err != nil {
|
|
33
|
+
return fmt.Errorf("sync parent dir of %s: %w", path, err)
|
|
34
|
+
}
|
|
35
|
+
return nil
|
|
36
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package config_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// 3.30: WriteAtomic per specs/workflow-repo-management "Config and
|
|
12
|
+
// workflow writes are atomically replaced": sibling temp, write+fsync, set
|
|
13
|
+
// mode, rename over destination, fsync parent directory.
|
|
14
|
+
|
|
15
|
+
func TestWriteAtomicCreatesAndReplaces(t *testing.T) {
|
|
16
|
+
dir := t.TempDir()
|
|
17
|
+
path := filepath.Join(dir, "workflow.yaml")
|
|
18
|
+
|
|
19
|
+
if err := config.WriteAtomic(path, []byte("v1"), 0644); err != nil {
|
|
20
|
+
t.Fatalf("WriteAtomic create failed: %v", err)
|
|
21
|
+
}
|
|
22
|
+
fi, _ := os.Stat(path)
|
|
23
|
+
if fi.Mode().Perm() != 0644 {
|
|
24
|
+
t.Fatalf("mode = %o, want 0644", fi.Mode().Perm())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if err := config.WriteAtomic(path, []byte("v2-longer-content"), 0644); err != nil {
|
|
28
|
+
t.Fatalf("WriteAtomic replace failed: %v", err)
|
|
29
|
+
}
|
|
30
|
+
got, _ := os.ReadFile(path)
|
|
31
|
+
if string(got) != "v2-longer-content" {
|
|
32
|
+
t.Fatalf("content = %q, want complete replacement", got)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestWriteAtomicSetsModeOnReplace(t *testing.T) {
|
|
37
|
+
dir := t.TempDir()
|
|
38
|
+
path := filepath.Join(dir, "config.yaml")
|
|
39
|
+
if err := config.WriteAtomic(path, []byte("a"), 0600); err != nil {
|
|
40
|
+
t.Fatal(err)
|
|
41
|
+
}
|
|
42
|
+
// Replace with a different mode; the rename must carry the new mode.
|
|
43
|
+
if err := config.WriteAtomic(path, []byte("b"), 0644); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
fi, _ := os.Stat(path)
|
|
47
|
+
if fi.Mode().Perm() != 0644 {
|
|
48
|
+
t.Fatalf("mode after replace = %o, want 0644", fi.Mode().Perm())
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestWriteAtomicLeavesNoTempOnSuccess(t *testing.T) {
|
|
53
|
+
dir := t.TempDir()
|
|
54
|
+
path := filepath.Join(dir, "f.yaml")
|
|
55
|
+
if err := config.WriteAtomic(path, []byte("x"), 0600); err != nil {
|
|
56
|
+
t.Fatal(err)
|
|
57
|
+
}
|
|
58
|
+
entries, _ := os.ReadDir(dir)
|
|
59
|
+
if len(entries) != 1 {
|
|
60
|
+
names := []string{}
|
|
61
|
+
for _, e := range entries {
|
|
62
|
+
names = append(names, e.Name())
|
|
63
|
+
}
|
|
64
|
+
t.Fatalf("leftover files after atomic write: %v", names)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func TestWriteAtomicFailureLeavesPriorFileUsable(t *testing.T) {
|
|
69
|
+
// A failed replacement leaves the previous complete file intact and
|
|
70
|
+
// readable. Make the destination's parent non-writable by its owner so
|
|
71
|
+
// the sibling temp file cannot be created; the existing destination is
|
|
72
|
+
// never touched. (Deterministic for a non-root owner, which the test
|
|
73
|
+
// environment is.)
|
|
74
|
+
dir := t.TempDir()
|
|
75
|
+
path := filepath.Join(dir, "workflow.yaml")
|
|
76
|
+
if err := config.WriteAtomic(path, []byte("good-v1"), 0644); err != nil {
|
|
77
|
+
t.Fatal(err)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if err := os.Chmod(dir, 0500); err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
err := config.WriteAtomic(path, []byte("bad-v2-partial"), 0644)
|
|
84
|
+
if cerr := os.Chmod(dir, 0700); cerr != nil {
|
|
85
|
+
t.Fatalf("restore dir perms: %v", cerr)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if err == nil {
|
|
89
|
+
t.Fatal("WriteAtomic into a non-writable dir succeeded; want failure")
|
|
90
|
+
}
|
|
91
|
+
got, rerr := os.ReadFile(path)
|
|
92
|
+
if rerr != nil {
|
|
93
|
+
t.Fatalf("prior file unreadable after failed write: %v", rerr)
|
|
94
|
+
}
|
|
95
|
+
if string(got) != "good-v1" {
|
|
96
|
+
t.Fatalf("prior file = %q, want intact good-v1", got)
|
|
97
|
+
}
|
|
98
|
+
}
|