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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. 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
+ }