relay-flow 0.1.0-alpha.0 → 0.2.1-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 (118) hide show
  1. package/README.md +160 -143
  2. package/cmd/relay-flow/commands_test.go +968 -0
  3. package/cmd/relay-flow/main.go +877 -175
  4. package/cmd/relay-flow/scenario_test.go +1310 -0
  5. package/cmd/relay-flow/serve.go +610 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/go.mod +69 -2
  8. package/go.sum +185 -0
  9. package/internal/config/config.go +88 -0
  10. package/internal/config/machine.go +99 -48
  11. package/internal/config/machine_test.go +248 -0
  12. package/internal/config/merge_test.go +118 -0
  13. package/internal/config/writeatomic.go +36 -0
  14. package/internal/config/writeatomic_test.go +98 -0
  15. package/internal/execution/goworkflows/activities.go +490 -0
  16. package/internal/execution/goworkflows/engine.go +520 -0
  17. package/internal/execution/goworkflows/engine_test.go +660 -0
  18. package/internal/execution/goworkflows/fakes_test.go +509 -0
  19. package/internal/execution/goworkflows/interpreter.go +613 -0
  20. package/internal/execution/goworkflows/logging_test.go +154 -0
  21. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  22. package/internal/execution/goworkflows/node_runtime_integration_test.go +133 -0
  23. package/internal/execution/goworkflows/node_runtime_test.go +510 -0
  24. package/internal/execution/goworkflows/projection.go +504 -0
  25. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  26. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  27. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  28. package/internal/harness/contract_test.go +174 -0
  29. package/internal/harness/factory.go +63 -0
  30. package/internal/harness/harness.go +41 -0
  31. package/internal/harness/opencode/opencode.go +166 -0
  32. package/internal/harness/opencode/opencode_test.go +50 -0
  33. package/internal/harness/plugin_selection_test.go +126 -0
  34. package/internal/identity/identity.go +37 -0
  35. package/internal/logging/logging.go +56 -0
  36. package/internal/logging/logging_test.go +116 -0
  37. package/internal/paths/paths.go +69 -0
  38. package/internal/recover/recover.go +115 -0
  39. package/internal/repo/poller.go +186 -0
  40. package/internal/repo/poller_test.go +327 -0
  41. package/internal/repo/repo.go +132 -0
  42. package/internal/repo/service.go +216 -0
  43. package/internal/repo/service_test.go +298 -0
  44. package/internal/retry/retry.go +118 -0
  45. package/internal/router/router.go +84 -0
  46. package/internal/router/router_test.go +231 -0
  47. package/internal/run/manager.go +121 -0
  48. package/internal/run/run.go +140 -0
  49. package/internal/run/run_identity_test.go +52 -0
  50. package/internal/run/run_manager_test.go +286 -0
  51. package/internal/runner/contract_test.go +259 -0
  52. package/internal/runner/factory.go +65 -0
  53. package/internal/runner/orca/orca.go +341 -172
  54. package/internal/runner/orca/orca_test.go +256 -143
  55. package/internal/runner/orca/orcacli/orcacli.go +220 -0
  56. package/internal/runner/orca/orcacli/orcacli_test.go +157 -0
  57. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  58. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +32 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  66. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  67. package/internal/runner/runner.go +54 -64
  68. package/internal/server/api_test.go +300 -0
  69. package/internal/server/client.go +192 -74
  70. package/internal/server/fixture_test.go +248 -0
  71. package/internal/server/server.go +425 -248
  72. package/internal/server/shutdown_test.go +116 -0
  73. package/internal/task/auth_test.go +48 -0
  74. package/internal/task/contract_test.go +225 -0
  75. package/internal/task/factory.go +119 -0
  76. package/internal/task/jira/auth.go +183 -0
  77. package/internal/task/jira/auth_test.go +107 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +254 -0
  80. package/internal/task/jira/helpers_test.go +70 -0
  81. package/internal/task/jira/jira.go +538 -0
  82. package/internal/task/jira/normalize.go +119 -0
  83. package/internal/task/jira/rest/adf.go +128 -0
  84. package/internal/task/jira/rest/client.go +573 -0
  85. package/internal/task/jira/rest/client_test.go +381 -0
  86. package/internal/task/jira/testdata/jira_search_issues.json +120 -0
  87. package/internal/task/jira/transition_defaults_test.go +158 -0
  88. package/internal/task/jira/validation_test.go +94 -0
  89. package/internal/task/task.go +84 -0
  90. package/internal/workflow/report.go +85 -0
  91. package/internal/workflow/report_test.go +259 -0
  92. package/internal/workflow/service.go +142 -0
  93. package/internal/workflow/store.go +136 -0
  94. package/internal/workflow/store_test.go +282 -0
  95. package/internal/workflow/workflow.go +345 -0
  96. package/internal/workflow/workflow_test.go +412 -0
  97. package/package.json +7 -2
  98. package/internal/acli/acli.go +0 -229
  99. package/internal/config/demo_test.go +0 -17
  100. package/internal/config/schema.go +0 -193
  101. package/internal/config/schema_test.go +0 -162
  102. package/internal/daemon/daemon.go +0 -218
  103. package/internal/daemon/daemon_test.go +0 -204
  104. package/internal/discovery/discovery.go +0 -122
  105. package/internal/discovery/discovery_test.go +0 -62
  106. package/internal/opencode/opencode.go +0 -26
  107. package/internal/orcacli/orcacli.go +0 -264
  108. package/internal/runner/orca/README.md +0 -64
  109. package/internal/runner/runner_test.go +0 -64
  110. package/internal/server/server_test.go +0 -195
  111. package/internal/tasks/jira/README.md +0 -69
  112. package/internal/tasks/jira/component_test.go +0 -16
  113. package/internal/tasks/jira/decode.go +0 -24
  114. package/internal/tasks/jira/jira.go +0 -231
  115. package/internal/tasks/jira/jira_test.go +0 -259
  116. package/internal/tasks/jira/jql_test.go +0 -16
  117. package/internal/tasks/tasks.go +0 -90
  118. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,968 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "database/sql"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net"
11
+ "net/http"
12
+ "os"
13
+ "os/exec"
14
+ "path/filepath"
15
+ "strings"
16
+ "sync"
17
+ "syscall"
18
+ "testing"
19
+ "time"
20
+
21
+ "github.com/charmbracelet/huh"
22
+ "github.com/rajpopat27/relay-flow/internal/config"
23
+ "github.com/rajpopat27/relay-flow/internal/repo"
24
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
25
+ "github.com/rajpopat27/relay-flow/internal/runner"
26
+ "github.com/rajpopat27/relay-flow/internal/server"
27
+ "github.com/rajpopat27/relay-flow/internal/task"
28
+ "github.com/rajpopat27/relay-flow/internal/workflow"
29
+ )
30
+
31
+ // 3.36 command surface, 3.31 init, 3.22 report exit codes. Settled seam (e):
32
+ // a thin run(args, stdin) int CLI entry in cmd/relay-flow, driven in-process
33
+ // with a temp relay-flow root. Red until 4.15 implements the parser.
34
+
35
+ // cli invokes the parser entry with args and stdin against a temp home.
36
+ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
37
+ t.Helper()
38
+ root := filepath.Join(home, ".relay-flow")
39
+ t.Setenv("RELAY_FLOW_HOME", root)
40
+ return run(args, strings.NewReader(stdin))
41
+ }
42
+
43
+ func TestCommandSurfaceExists(t *testing.T) {
44
+ home := t.TempDir()
45
+ commands := [][]string{
46
+ {"init"}, {"task", "auth"}, {"serve", "--recover"}, {"stop"}, {"report"},
47
+ {"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
48
+ {"workflow", "list"}, {"workflow", "get", "--name", "x"},
49
+ {"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
50
+ {"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"},
51
+ }
52
+ for _, argv := range commands {
53
+ // Recognized commands do not exit 2 ("usage/unknown"); they may exit
54
+ // 0 or 1 (server/validation) depending on environment.
55
+ if code := cli(t, home, "", argv...); code == 2 {
56
+ t.Fatalf("command %v not recognized (exit 2)", argv)
57
+ }
58
+ }
59
+ }
60
+
61
+ func TestUnknownFlagExits2(t *testing.T) {
62
+ if code := cli(t, t.TempDir(), "", "workflow", "list", "--bogus"); code != 2 {
63
+ t.Fatalf("unknown flag exit = %d, want 2", code)
64
+ }
65
+ }
66
+
67
+ // Required-flag enforcement: commands that take an identifier exit 2 (usage)
68
+ // when it is missing, before any server contact.
69
+ func TestRequiredFlagMissingExits2(t *testing.T) {
70
+ home := t.TempDir()
71
+ for _, argv := range [][]string{
72
+ {"workflow", "submit"}, // missing --file
73
+ {"workflow", "remove"}, // missing --name
74
+ {"workflow", "get"}, // missing --name
75
+ {"repo", "remove"}, // missing --name
76
+ {"repo", "get"}, // missing --name
77
+ {"run", "get"}, // missing --ticket
78
+ {"run", "cancel"}, // missing --ticket
79
+ } {
80
+ if code := cli(t, home, "", argv...); code != 2 {
81
+ t.Fatalf("%v with missing required flag exit = %d, want 2", argv, code)
82
+ }
83
+ }
84
+ }
85
+
86
+ func TestRunListRowShowsLifecycleAndActiveRetry(t *testing.T) {
87
+ next := time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC)
88
+ r := runsvc.Run{
89
+ ID: "payments/basicFlow/PAY-101", Workflow: "basicFlow", State: runsvc.StateStarting,
90
+ Retry: &runsvc.RetryStatus{Attempt: 3, LastError: "jira unavailable", NextRetryAt: next},
91
+ }
92
+ got := formatRunListRow(r)
93
+ for _, want := range []string{"starting", "retrying", "attempt=3", "next=2026-08-25T12:00:00Z", `error="jira unavailable"`} {
94
+ if !strings.Contains(got, want) {
95
+ t.Fatalf("run list row %q missing %q", got, want)
96
+ }
97
+ }
98
+ }
99
+
100
+ // initHome seeds a temp relay-flow home via the real init path so serve
101
+ // has a valid machine config and database (normal serve refuses to start
102
+ // without them per 5.5).
103
+ func initHome(t *testing.T, home string) {
104
+ t.Helper()
105
+ if code := cli(t, home, "", initArgs()...); code != 0 {
106
+ t.Fatalf("init exit = %d, want 0", code)
107
+ }
108
+ }
109
+
110
+ func initArgs() []string {
111
+ return []string{"init", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"}
112
+ }
113
+
114
+ func initInput() string {
115
+ return "jira\norca\nopencode\n"
116
+ }
117
+
118
+ const authTestPlugin = "auth-dispatch-test"
119
+
120
+ var (
121
+ registerAuthTestPlugin sync.Once
122
+ authTestArgs []string
123
+ authTestInput string
124
+ )
125
+
126
+ func ensureAuthTestPlugin() {
127
+ registerAuthTestPlugin.Do(func() {
128
+ task.Register(authTestPlugin, task.Factory{
129
+ RequiredRepoKeys: func() []string { return nil },
130
+ TaskScopeKey: func(_, _ config.RawValues) (string, error) { return "auth-test", nil },
131
+ Auth: func(_ context.Context, args []string, stdin io.Reader) error {
132
+ authTestArgs = append([]string(nil), args...)
133
+ body, err := io.ReadAll(stdin)
134
+ if err != nil {
135
+ return err
136
+ }
137
+ authTestInput = string(body)
138
+ return nil
139
+ },
140
+ New: func(context.Context, task.RepoSpec) (task.System, error) { return nil, nil },
141
+ })
142
+ })
143
+ }
144
+
145
+ func TestTaskAuthDispatchesSelectedPlugin(t *testing.T) {
146
+ ensureAuthTestPlugin()
147
+ home := t.TempDir()
148
+ root := filepath.Join(home, ".relay-flow")
149
+ if err := os.MkdirAll(root, 0o700); err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ if err := config.SaveMachine(filepath.Join(root, "config.yaml"), &config.Machine{TaskPlugin: authTestPlugin}); err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ authTestArgs = nil
156
+ authTestInput = ""
157
+ if code := cli(t, home, "plugin-owned-input", "task", "auth", "--custom", "value"); code != 0 {
158
+ t.Fatalf("task auth exit = %d, want 0", code)
159
+ }
160
+ if got := strings.Join(authTestArgs, " "); got != "--custom value" {
161
+ t.Fatalf("plugin args = %q", got)
162
+ }
163
+ if authTestInput != "plugin-owned-input" {
164
+ t.Fatalf("plugin stdin = %q", authTestInput)
165
+ }
166
+ }
167
+
168
+ // 3.29 (lock): serve creates server.lock owner-only. Asserted through the
169
+ // serve fixture.
170
+ func TestServerLockIsOwnerOnly(t *testing.T) {
171
+ home := t.TempDir()
172
+ initHome(t, home)
173
+ // Start serve in the background of the test via the parser entry; it
174
+ // creates the flock file then blocks. We assert the lock file's mode.
175
+ go cli(t, home, "", "serve")
176
+ lock := filepath.Join(home, ".relay-flow", "server.lock")
177
+ var fi os.FileInfo
178
+ var err error
179
+ deadline := time.Now().Add(5 * time.Second)
180
+ for time.Now().Before(deadline) {
181
+ fi, err = os.Stat(lock)
182
+ if err == nil {
183
+ break
184
+ }
185
+ time.Sleep(20 * time.Millisecond)
186
+ }
187
+ if err != nil {
188
+ t.Fatalf("server.lock not created: %v", err)
189
+ }
190
+ if fi.Mode().Perm() != 0600 {
191
+ t.Fatalf("server.lock mode = %o, want 0600", fi.Mode().Perm())
192
+ }
193
+ }
194
+
195
+ // 3.29 (socket): the serve startup path owns server.sock creation and chmods
196
+ // it 0600. Asserted through the serve fixture (the component that binds the
197
+ // socket), not a test-created listener.
198
+ func TestServerSocketIsOwnerOnly(t *testing.T) {
199
+ home := t.TempDir()
200
+ initHome(t, home)
201
+ go cli(t, home, "", "serve")
202
+ sock := filepath.Join(home, ".relay-flow", "server.sock")
203
+ var fi os.FileInfo
204
+ var err error
205
+ deadline := time.Now().Add(5 * time.Second)
206
+ for time.Now().Before(deadline) {
207
+ fi, err = os.Stat(sock)
208
+ if err == nil {
209
+ break
210
+ }
211
+ time.Sleep(20 * time.Millisecond)
212
+ }
213
+ if err != nil {
214
+ t.Fatalf("server.sock not created by serve: %v", err)
215
+ }
216
+ if fi.Mode().Perm() != 0600 {
217
+ t.Fatalf("server.sock mode = %o, want 0600", fi.Mode().Perm())
218
+ }
219
+ }
220
+
221
+ func TestReportReadsOneJSONObjectFromStdin(t *testing.T) {
222
+ if code := cli(t, t.TempDir(), "{not json", "report"); code != 1 {
223
+ t.Fatalf("malformed report JSON exit = %d, want 1", code)
224
+ }
225
+ }
226
+
227
+ // 3.22: report ack semantics — exit 0 on any ack (incl. stale/duplicate),
228
+ // 1 on server/validation failure, never non-zero for a stale/duplicate.
229
+ // Drives the real CLI report against an in-process server (seam d) over the
230
+ // relay-flow Unix socket in the temp home.
231
+ func TestReportAckMatrix(t *testing.T) {
232
+ valid := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
233
+
234
+ // Any ack (accepted fresh, or accepted duplicate/stale) exits 0.
235
+ for name, ack := range map[string]runsvc.ReportAck{
236
+ "accepted fresh": {Accepted: true, Duplicate: false},
237
+ "accepted duplicate": {Accepted: true, Duplicate: true},
238
+ } {
239
+ home := t.TempDir()
240
+ serveAck(t, home, ack, nil) // seam d server on the home socket
241
+ if code := cli(t, home, valid, "report"); code != 0 {
242
+ t.Fatalf("%s: exit = %d, want 0", name, code)
243
+ }
244
+ }
245
+
246
+ // Server/validation failure exits 1.
247
+ home := t.TempDir()
248
+ serveAck(t, home, runsvc.ReportAck{}, errReportInvalid)
249
+ if code := cli(t, home, valid, "report"); code != 1 {
250
+ t.Fatalf("validation failure exit = %d, want 1", code)
251
+ }
252
+ }
253
+
254
+ func TestReportUnreachableServerExits1(t *testing.T) {
255
+ valid := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
256
+ if code := cli(t, t.TempDir(), valid, "report"); code != 1 {
257
+ t.Fatalf("unreachable server exit = %d, want 1", code)
258
+ }
259
+ }
260
+
261
+ func TestInitRefusesToOverwrite(t *testing.T) {
262
+ home := t.TempDir()
263
+ // init reads the three plugin selections from stdin.
264
+ if code := cli(t, home, initInput(), "init"); code != 0 {
265
+ t.Fatalf("first init exit = %d, want 0", code)
266
+ }
267
+ cfgPath := filepath.Join(home, ".relay-flow", "config.yaml")
268
+ dbPath := filepath.Join(home, ".relay-flow", "state.db")
269
+ cfg := readFile(t, cfgPath)
270
+ for _, want := range []string{
271
+ "taskPlugin:", "runnerPlugin:", "harnessPlugin:",
272
+ "keepTerminalsAlive: true", "keepSessionsAlive: true",
273
+ } {
274
+ if !strings.Contains(cfg, want) {
275
+ t.Fatalf("config missing %q:\n%s", want, cfg)
276
+ }
277
+ }
278
+ dbBefore := readFile(t, dbPath)
279
+ // state.db is a REAL SQLite database, not an arbitrary non-empty file:
280
+ // the first 16 bytes are the SQLite format-3 magic header.
281
+ if !strings.HasPrefix(dbBefore, "SQLite format 3\x00") {
282
+ t.Fatalf("state.db is not a SQLite database (missing magic header); got %q", dbBefore[:min(16, len(dbBefore))])
283
+ }
284
+
285
+ if code := cli(t, home, initInput(), "init"); code == 0 {
286
+ t.Fatal("init overwrote existing config/history")
287
+ }
288
+ if readFile(t, cfgPath) != cfg {
289
+ t.Fatal("init changed existing machine config")
290
+ }
291
+ if readFile(t, dbPath) != dbBefore {
292
+ t.Fatal("init changed existing execution history")
293
+ }
294
+ }
295
+
296
+ func TestInitSelectionTitlesAndSingletons(t *testing.T) {
297
+ for _, tc := range []struct {
298
+ title string
299
+ value string
300
+ }{
301
+ {"Select task system", "jira"},
302
+ {"Select runner", "orca"},
303
+ {"Select harness", "opencode"},
304
+ } {
305
+ var selected string
306
+ field, err := pluginSelectField(tc.title, []string{tc.value}, &selected)
307
+ if err != nil {
308
+ t.Fatal(err)
309
+ }
310
+ if field == nil {
311
+ t.Fatalf("%s singleton field is nil", tc.title)
312
+ }
313
+ var singletonOut bytes.Buffer
314
+ if err := field.RunAccessible(&singletonOut, strings.NewReader("1\n")); err != nil {
315
+ t.Fatal(err)
316
+ }
317
+ if selected != tc.value || !strings.Contains(singletonOut.String(), tc.title) {
318
+ t.Fatalf("%s singleton selection = %q, output %q", tc.title, selected, singletonOut.String())
319
+ }
320
+
321
+ field, err = pluginSelectField(tc.title, []string{tc.value, "other"}, &selected)
322
+ if err != nil {
323
+ t.Fatal(err)
324
+ }
325
+ var out bytes.Buffer
326
+ if err := field.RunAccessible(&out, strings.NewReader("1\n")); err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ if !strings.Contains(out.String(), tc.title) {
330
+ t.Fatalf("selection output %q missing title %q", out.String(), tc.title)
331
+ }
332
+ }
333
+ }
334
+
335
+ func TestInitPrintsSelectedValuesAndCompletion(t *testing.T) {
336
+ code, out := captureStdout(t, func() int {
337
+ return cli(t, t.TempDir(), "", initArgs()...)
338
+ })
339
+ if code != 0 {
340
+ t.Fatalf("init exit = %d", code)
341
+ }
342
+ for _, want := range []string{"Task system: jira", "Runner: orca", "Harness: opencode", "Relay-flow initialized"} {
343
+ if !strings.Contains(out, want) {
344
+ t.Fatalf("init output %q missing %q", out, want)
345
+ }
346
+ }
347
+ }
348
+
349
+ func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
350
+ home := t.TempDir()
351
+ if code := cli(t, home, "", initArgs()...); code != 0 {
352
+ t.Fatalf("init exit = %d", code)
353
+ }
354
+ root := filepath.Join(home, ".relay-flow")
355
+ configText := readFile(t, filepath.Join(root, "config.yaml"))
356
+ if strings.Contains(configText, "jira-site") || strings.Contains(configText, "email") || strings.Contains(configText, "token") {
357
+ t.Fatalf("normal config contains task credentials: %s", configText)
358
+ }
359
+ if _, err := os.Stat(filepath.Join(root, "credentials.yaml")); !os.IsNotExist(err) {
360
+ t.Fatalf("init wrote credentials.yaml: %v", err)
361
+ }
362
+ }
363
+
364
+ func TestInitForceRejectsRunningServerAndNonterminalRun(t *testing.T) {
365
+ t.Run("server running", func(t *testing.T) {
366
+ home := t.TempDir()
367
+ initHome(t, home)
368
+ lockPath := filepath.Join(home, ".relay-flow", "server.lock")
369
+ lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
370
+ if err != nil {
371
+ t.Fatal(err)
372
+ }
373
+ defer lock.Close()
374
+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
375
+ t.Fatal(err)
376
+ }
377
+ defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
378
+ if code := cli(t, home, "", "init", "--force",
379
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 1 {
380
+ t.Fatalf("forced init with server lock exit = %d, want 1", code)
381
+ }
382
+ })
383
+
384
+ t.Run("nonterminal run", func(t *testing.T) {
385
+ home := t.TempDir()
386
+ initHome(t, home)
387
+ root := filepath.Join(home, ".relay-flow")
388
+ insertRun(t, filepath.Join(root, "state.db"), "active", "starting")
389
+ configBefore := readFile(t, filepath.Join(root, "config.yaml"))
390
+ if code := cli(t, home, "", "init", "--force",
391
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 1 {
392
+ t.Fatalf("forced init with active run exit = %d, want 1", code)
393
+ }
394
+ if got := readFile(t, filepath.Join(root, "config.yaml")); got != configBefore {
395
+ t.Fatal("rejected forced init changed config")
396
+ }
397
+ })
398
+ }
399
+
400
+ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
401
+ home := t.TempDir()
402
+ initHome(t, home)
403
+ root := filepath.Join(home, ".relay-flow")
404
+ dbPath := filepath.Join(root, "state.db")
405
+ insertRun(t, dbPath, "completed", "completed")
406
+ dbBefore, err := os.Stat(dbPath)
407
+ if err != nil {
408
+ t.Fatal(err)
409
+ }
410
+
411
+ cfg, err := config.LoadMachine(filepath.Join(root, "config.yaml"))
412
+ if err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ cfg.Repos = map[string]config.Repo{"existing": {Path: "/srv/existing"}}
416
+ if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
417
+ t.Fatal(err)
418
+ }
419
+ workflowDir := filepath.Join(root, "workflows")
420
+ if err := os.MkdirAll(workflowDir, 0700); err != nil {
421
+ t.Fatal(err)
422
+ }
423
+ workflowPath := filepath.Join(workflowDir, "saved.yaml")
424
+ logPath := filepath.Join(root, "server.log")
425
+ if err := os.WriteFile(workflowPath, []byte("saved workflow\n"), 0644); err != nil {
426
+ t.Fatal(err)
427
+ }
428
+ if err := os.WriteFile(logPath, []byte("saved log\n"), 0600); err != nil {
429
+ t.Fatal(err)
430
+ }
431
+
432
+ args := append([]string{"init", "--force"}, initArgs()[1:]...)
433
+ if code := cli(t, home, "", args...); code != 0 {
434
+ t.Fatalf("forced init exit = %d, want 0", code)
435
+ }
436
+ if got := readFile(t, workflowPath); got != "saved workflow\n" {
437
+ t.Fatalf("workflow changed: %q", got)
438
+ }
439
+ if got := readFile(t, logPath); got != "saved log\n" {
440
+ t.Fatalf("log changed: %q", got)
441
+ }
442
+ dbAfter, err := os.Stat(dbPath)
443
+ if err != nil {
444
+ t.Fatal(err)
445
+ }
446
+ if !os.SameFile(dbBefore, dbAfter) {
447
+ t.Fatal("forced init recreated state.db")
448
+ }
449
+ cfg, err = config.LoadMachine(filepath.Join(root, "config.yaml"))
450
+ if err != nil {
451
+ t.Fatal(err)
452
+ }
453
+ if _, ok := cfg.Repos["existing"]; !ok {
454
+ t.Fatal("forced init removed existing repo config")
455
+ }
456
+ db, err := sql.Open("sqlite", dbPath)
457
+ if err != nil {
458
+ t.Fatal(err)
459
+ }
460
+ defer db.Close()
461
+ var state string
462
+ if err := db.QueryRow(`SELECT state FROM relay_runs WHERE id = 'completed'`).Scan(&state); err != nil {
463
+ t.Fatalf("completed history missing: %v", err)
464
+ }
465
+ if state != "completed" {
466
+ t.Fatalf("completed history state = %q", state)
467
+ }
468
+ }
469
+
470
+ // 8.2: --task-plugin/--runner-plugin/--harness-plugin run init without any
471
+ // prompt/stdin and write the same machine config as the stdin path.
472
+ func TestInitFlagsNonInteractive(t *testing.T) {
473
+ home := t.TempDir()
474
+ // Flags fully replace stdin: empty stdin must still succeed.
475
+ if code := cli(t, home, "", initArgs()...); code != 0 {
476
+ t.Fatalf("flagged init exit = %d, want 0", code)
477
+ }
478
+ cfgFlags := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml"))
479
+
480
+ home2 := t.TempDir()
481
+ if code := cli(t, home2, initInput(), "init"); code != 0 {
482
+ t.Fatalf("stdin init exit = %d, want 0", code)
483
+ }
484
+ cfgStdin := readFile(t, filepath.Join(home2, ".relay-flow", "config.yaml"))
485
+ if cfgFlags != cfgStdin {
486
+ t.Fatalf("flagged vs stdin config differ:\nflags:\n%s\nstdin:\n%s", cfgFlags, cfgStdin)
487
+ }
488
+
489
+ // Partial flags are a usage error and must not write config.
490
+ home3 := t.TempDir()
491
+ if code := cli(t, home3, "", "init", "--task-plugin", "jira"); code != 2 {
492
+ t.Fatalf("partial flags exit = %d, want 2", code)
493
+ }
494
+ if _, err := os.Stat(filepath.Join(home3, ".relay-flow", "config.yaml")); !os.IsNotExist(err) {
495
+ t.Fatal("partial-flag init wrote config")
496
+ }
497
+ }
498
+
499
+ func readFile(t *testing.T, path string) string {
500
+ t.Helper()
501
+ b, err := os.ReadFile(path)
502
+ if err != nil {
503
+ t.Fatalf("read %s: %v", path, err)
504
+ }
505
+ return string(b)
506
+ }
507
+
508
+ func min(a, b int) int {
509
+ if a < b {
510
+ return a
511
+ }
512
+ return b
513
+ }
514
+
515
+ func captureStdout(t *testing.T, fn func() int) (int, string) {
516
+ t.Helper()
517
+ old := os.Stdout
518
+ r, w, err := os.Pipe()
519
+ if err != nil {
520
+ t.Fatal(err)
521
+ }
522
+ os.Stdout = w
523
+ done := make(chan []byte, 1)
524
+ go func() {
525
+ b, _ := io.ReadAll(r)
526
+ done <- b
527
+ }()
528
+ code := fn()
529
+ _ = w.Close()
530
+ os.Stdout = old
531
+ out := <-done
532
+ _ = r.Close()
533
+ return code, string(out)
534
+ }
535
+
536
+ func insertRun(t *testing.T, path, id, state string) {
537
+ t.Helper()
538
+ db, err := sql.Open("sqlite", path)
539
+ if err != nil {
540
+ t.Fatal(err)
541
+ }
542
+ defer db.Close()
543
+ now := time.Now().UTC()
544
+ var finished any
545
+ if state == "completed" || state == "canceled" {
546
+ finished = now
547
+ }
548
+ _, err = db.Exec(`INSERT INTO relay_runs
549
+ (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
550
+ VALUES (?, 'repo', 'workflow', ?, ?, ?, ?, ?, ?)`, id, id, id, state, now, now, finished)
551
+ if err != nil {
552
+ t.Fatal(err)
553
+ }
554
+ }
555
+
556
+ // 8.4: fully-flagged repo register never prompts and produces the same
557
+ // repo entry the interactive run would post.
558
+ type registerServer struct {
559
+ ackServer // embed for the unreachable stubs
560
+ fields []string
561
+ gotInput *repo.RegisterInput
562
+ gotInputs []repo.RegisterInput
563
+ successful []repo.RegisterInput
564
+ failName string
565
+ calls int
566
+ }
567
+
568
+ func (s *registerServer) TaskFields(context.Context) ([]string, error) {
569
+ s.calls++
570
+ return s.fields, nil
571
+ }
572
+ func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput) (repo.Info, error) {
573
+ s.calls++
574
+ cp := in
575
+ s.gotInput = &cp
576
+ s.gotInputs = append(s.gotInputs, cp)
577
+ if in.Name == s.failName {
578
+ return repo.Info{}, errors.New("registration failed")
579
+ }
580
+ s.successful = append(s.successful, cp)
581
+ return repo.Info{Name: in.Name, Path: in.Path, TaskConfig: in.TaskConfig}, nil
582
+ }
583
+
584
+ func serveRegister(t *testing.T, home string, fields []string) *registerServer {
585
+ t.Helper()
586
+ root := filepath.Join(home, ".relay-flow")
587
+ if err := os.MkdirAll(root, 0700); err != nil {
588
+ t.Fatal(err)
589
+ }
590
+ sock := filepath.Join(root, "server.sock")
591
+ ln, err := net.Listen("unix", sock)
592
+ if err != nil {
593
+ t.Fatal(err)
594
+ }
595
+ deps := &registerServer{fields: fields}
596
+ srv := &http.Server{Handler: server.New(deps)}
597
+ go srv.Serve(ln)
598
+ t.Cleanup(func() {
599
+ _ = srv.Shutdown(context.Background())
600
+ _ = os.Remove(sock)
601
+ })
602
+ return deps
603
+ }
604
+
605
+ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
606
+ home := t.TempDir()
607
+ deps := serveRegister(t, home, []string{"project", "component"})
608
+
609
+ // Fully-flagged run: no stdin, no TTY — must not prompt.
610
+ code := cli(t, home, "", "repo", "register",
611
+ "--name", "payments", "--path", "/srv/payments",
612
+ "--set", "project=PAY")
613
+ if code != 0 {
614
+ t.Fatalf("fully-flagged register exit = %d, want 0", code)
615
+ }
616
+ if deps.gotInput == nil {
617
+ t.Fatal("server never received registration")
618
+ }
619
+ if deps.gotInput.Name != "payments" || deps.gotInput.Path != "/srv/payments" {
620
+ t.Fatalf("name/path = %q/%q", deps.gotInput.Name, deps.gotInput.Path)
621
+ }
622
+ if deps.gotInput.TaskConfig["project"] != "PAY" || deps.gotInput.TaskConfig["component"] != "payments" {
623
+ t.Fatalf("taskConfig = %v", deps.gotInput.TaskConfig)
624
+ }
625
+
626
+ // Missing project is a usage error; component is derived from --name.
627
+ // is allowed to hit the server (RegisterRepo must not be called).
628
+ home2 := t.TempDir()
629
+ deps2 := serveRegister(t, home2, []string{"project", "component"})
630
+ if code := cli(t, home2, "", "repo", "register",
631
+ "--name", "x", "--path", "/x"); code != 2 {
632
+ t.Fatalf("missing required key exit = %d, want 2", code)
633
+ }
634
+ if deps2.gotInput != nil {
635
+ t.Fatal("RegisterRepo called despite missing required key")
636
+ }
637
+
638
+ // Missing --name/--path must exit 2 with ZERO server contact.
639
+ home3 := t.TempDir()
640
+ deps3 := serveRegister(t, home3, []string{"project"})
641
+ for _, argv := range [][]string{
642
+ {"repo", "register", "--path", "/x", "--set", "project=PAY"},
643
+ {"repo", "register", "--name", "x", "--set", "project=PAY"},
644
+ {"repo", "register", "--set", "project=PAY"},
645
+ } {
646
+ before := deps3.calls
647
+ if code := cli(t, home3, "", argv...); code != 2 {
648
+ t.Fatalf("%v exit = %d, want 2", argv, code)
649
+ }
650
+ if deps3.calls != before {
651
+ t.Fatalf("%v contacted server (%d calls)", argv, deps3.calls-before)
652
+ }
653
+ }
654
+
655
+ // Invalid --set forms are usage errors at parse time: no server contact.
656
+ home4 := t.TempDir()
657
+ deps4 := serveRegister(t, home4, []string{"project"})
658
+ for _, argv := range [][]string{
659
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "noequals"},
660
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "=v"},
661
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "project="},
662
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "project=A", "--set", "project=B"},
663
+ } {
664
+ before := deps4.calls
665
+ if code := cli(t, home4, "", argv...); code != 2 {
666
+ t.Fatalf("%v exit = %d, want 2", argv, code)
667
+ }
668
+ if deps4.calls != before {
669
+ t.Fatalf("%v contacted server", argv)
670
+ }
671
+ }
672
+
673
+ // Unknown --set keys are usage errors (flags map exactly to required keys).
674
+ home5 := t.TempDir()
675
+ serveRegister(t, home5, []string{"project"})
676
+ if code := cli(t, home5, "", "repo", "register",
677
+ "--name", "x", "--path", "/x",
678
+ "--set", "project=PAY", "--set", "bogus=v"); code != 2 {
679
+ t.Fatalf("unknown key exit = %d, want 2", code)
680
+ }
681
+
682
+ // Component can never be prompted or overridden.
683
+ home6 := t.TempDir()
684
+ deps6 := serveRegister(t, home6, []string{"project", "component"})
685
+ if code := cli(t, home6, "", "repo", "register",
686
+ "--name", "x", "--path", "/x",
687
+ "--set", "project=PAY", "--set", "component=override"); code != 2 {
688
+ t.Fatalf("component override exit = %d, want 2", code)
689
+ }
690
+ if deps6.gotInput != nil {
691
+ t.Fatal("component override reached RegisterRepo")
692
+ }
693
+ }
694
+
695
+ func TestRepoMultiSelectAndSharedJiraMapping(t *testing.T) {
696
+ selected := []int{0, 1}
697
+ field := repoMultiSelect([]huh.Option[int]{
698
+ huh.NewOption("payments", 0),
699
+ huh.NewOption("checkout", 1),
700
+ }, &selected)
701
+ var out bytes.Buffer
702
+ if err := field.RunAccessible(&out, strings.NewReader("0\n")); err != nil {
703
+ t.Fatal(err)
704
+ }
705
+ if !strings.Contains(out.String(), "Select repositories") {
706
+ t.Fatalf("multi-select output %q missing title", out.String())
707
+ }
708
+ if fmt.Sprint(selected) != "[0 1]" {
709
+ t.Fatalf("selected = %v, want [0 1]", selected)
710
+ }
711
+ if got := registrationSharedFields([]string{"project", "component"}); fmt.Sprint(got) != "[project]" {
712
+ t.Fatalf("prompted fields = %v, want project only", got)
713
+ }
714
+
715
+ home := t.TempDir()
716
+ deps := serveRegister(t, home, []string{"project", "component"})
717
+ client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
718
+ candidates := []runner.RepoCandidate{
719
+ {Name: "payments", Path: "/srv/payments"},
720
+ {Name: "checkout", Path: "/srv/checkout"},
721
+ }
722
+ if err := registerSelectedRepos(context.Background(), client, candidates, selected,
723
+ []string{"project", "component"}, kvFlags{"project": "PAY"}); err != nil {
724
+ t.Fatal(err)
725
+ }
726
+ if len(deps.successful) != 2 {
727
+ t.Fatalf("registrations = %d, want 2", len(deps.successful))
728
+ }
729
+ for _, input := range deps.successful {
730
+ if input.TaskConfig["project"] != "PAY" || input.TaskConfig["component"] != input.Name {
731
+ t.Fatalf("registration %+v has task config %v", input, input.TaskConfig)
732
+ }
733
+ }
734
+ }
735
+
736
+ func TestRepoRegistrationPartialFailureKeepsPriorSuccess(t *testing.T) {
737
+ home := t.TempDir()
738
+ deps := serveRegister(t, home, []string{"project", "component"})
739
+ deps.failName = "checkout"
740
+ client := server.NewClient(filepath.Join(home, ".relay-flow", "server.sock"))
741
+ candidates := []runner.RepoCandidate{
742
+ {Name: "payments", Path: "/srv/payments"},
743
+ {Name: "checkout", Path: "/srv/checkout"},
744
+ {Name: "later", Path: "/srv/later"},
745
+ }
746
+ err := registerSelectedRepos(context.Background(), client, candidates, []int{0, 1, 2},
747
+ []string{"project", "component"}, kvFlags{"project": "PAY"})
748
+ if err == nil || !strings.Contains(err.Error(), "checkout") {
749
+ t.Fatalf("partial failure = %v, want failed repo name", err)
750
+ }
751
+ if len(deps.successful) != 1 || deps.successful[0].Name != "payments" {
752
+ t.Fatalf("successful registrations = %+v, want payments preserved", deps.successful)
753
+ }
754
+ if len(deps.gotInputs) != 2 {
755
+ t.Fatalf("registration attempts = %d, want stop after failed second repo", len(deps.gotInputs))
756
+ }
757
+ }
758
+
759
+ func TestBackgroundServeReadinessAndStop(t *testing.T) {
760
+ home := t.TempDir()
761
+ root := filepath.Join(home, ".relay-flow")
762
+ t.Setenv("RELAY_FLOW_HOME", root)
763
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
764
+ t.Fatalf("init exit = %d", code)
765
+ }
766
+ binary := buildCLIBinary(t)
767
+ start := exec.Command(binary, "serve", "--background")
768
+ start.Env = os.Environ()
769
+ out, err := start.CombinedOutput()
770
+ if err != nil {
771
+ t.Fatalf("background serve: %v\n%s", err, out)
772
+ }
773
+ if !strings.Contains(string(out), "Relay-flow server started") {
774
+ t.Fatalf("background output = %q", out)
775
+ }
776
+ client := server.NewClient(filepath.Join(root, "server.sock"))
777
+ if _, err := client.ListRepos(context.Background()); err != nil {
778
+ t.Fatalf("ready server did not respond: %v", err)
779
+ }
780
+ stop := exec.Command(binary, "stop")
781
+ stop.Env = os.Environ()
782
+ if out, err := stop.CombinedOutput(); err != nil {
783
+ t.Fatalf("stop: %v\n%s", err, out)
784
+ }
785
+ waitForServerStop(t, client)
786
+ }
787
+
788
+ func TestBackgroundServeFailurePointsToLog(t *testing.T) {
789
+ home := t.TempDir()
790
+ root := filepath.Join(home, ".relay-flow")
791
+ t.Setenv("RELAY_FLOW_HOME", root)
792
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
793
+ t.Fatalf("init exit = %d", code)
794
+ }
795
+ if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte("invalid: true\n"), 0600); err != nil {
796
+ t.Fatal(err)
797
+ }
798
+ binary := buildCLIBinary(t)
799
+ start := exec.Command(binary, "serve", "--background")
800
+ start.Env = os.Environ()
801
+ out, err := start.CombinedOutput()
802
+ if err == nil {
803
+ t.Fatalf("background serve unexpectedly succeeded: %s", out)
804
+ }
805
+ if !strings.Contains(string(out), "server.log") {
806
+ t.Fatalf("background failure %q does not point to server.log", out)
807
+ }
808
+ log := readFile(t, filepath.Join(root, "server.log"))
809
+ if !strings.Contains(log, "server startup failed") {
810
+ t.Fatalf("server.log missing startup error: %q", log)
811
+ }
812
+ }
813
+
814
+ func TestForegroundServeRemainsBlocking(t *testing.T) {
815
+ home := t.TempDir()
816
+ root := filepath.Join(home, ".relay-flow")
817
+ t.Setenv("RELAY_FLOW_HOME", root)
818
+ if code := run(initArgs(), strings.NewReader("")); code != 0 {
819
+ t.Fatalf("init exit = %d", code)
820
+ }
821
+ binary := buildCLIBinary(t)
822
+ serve := exec.Command(binary, "serve")
823
+ serve.Env = os.Environ()
824
+ if err := serve.Start(); err != nil {
825
+ t.Fatal(err)
826
+ }
827
+ wait := make(chan error, 1)
828
+ go func() { wait <- serve.Wait() }()
829
+ t.Cleanup(func() {
830
+ if serve.Process != nil {
831
+ _ = serve.Process.Kill()
832
+ }
833
+ })
834
+ client := server.NewClient(filepath.Join(root, "server.sock"))
835
+ waitForServer(t, client)
836
+ select {
837
+ case err := <-wait:
838
+ t.Fatalf("foreground serve returned before stop: %v", err)
839
+ case <-time.After(200 * time.Millisecond):
840
+ }
841
+ stop := exec.Command(binary, "stop")
842
+ stop.Env = os.Environ()
843
+ if out, err := stop.CombinedOutput(); err != nil {
844
+ t.Fatalf("stop: %v\n%s", err, out)
845
+ }
846
+ select {
847
+ case err := <-wait:
848
+ if err != nil {
849
+ t.Fatalf("foreground serve exit: %v", err)
850
+ }
851
+ case <-time.After(5 * time.Second):
852
+ t.Fatal("foreground serve did not exit after stop")
853
+ }
854
+ }
855
+
856
+ func buildCLIBinary(t *testing.T) string {
857
+ t.Helper()
858
+ binary := filepath.Join(t.TempDir(), "relay-flow")
859
+ cmd := exec.Command("go", "build", "-buildvcs=false", "-o", binary, ".")
860
+ out, err := cmd.CombinedOutput()
861
+ if err != nil {
862
+ t.Fatalf("build relay-flow: %v\n%s", err, out)
863
+ }
864
+ return binary
865
+ }
866
+
867
+ func waitForServer(t *testing.T, client *server.Client) {
868
+ t.Helper()
869
+ deadline := time.Now().Add(5 * time.Second)
870
+ for time.Now().Before(deadline) {
871
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
872
+ _, err := client.ListRepos(ctx)
873
+ cancel()
874
+ if err == nil {
875
+ return
876
+ }
877
+ time.Sleep(20 * time.Millisecond)
878
+ }
879
+ t.Fatal("server did not become ready")
880
+ }
881
+
882
+ func waitForServerStop(t *testing.T, client *server.Client) {
883
+ t.Helper()
884
+ deadline := time.Now().Add(5 * time.Second)
885
+ for time.Now().Before(deadline) {
886
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
887
+ _, err := client.ListRepos(ctx)
888
+ cancel()
889
+ if err != nil {
890
+ return
891
+ }
892
+ time.Sleep(20 * time.Millisecond)
893
+ }
894
+ t.Fatal("server remained reachable after stop")
895
+ }
896
+
897
+ var errReportInvalid = errors.New("invalid report")
898
+
899
+ // ackServer is the minimal server.Deps implementation for the report path.
900
+ // Only SubmitReport is exercised; all other methods are unreachable stubs.
901
+ type ackServer struct {
902
+ ack runsvc.ReportAck
903
+ err error
904
+ }
905
+
906
+ func (s *ackServer) SubmitReport(context.Context, runsvc.ReportRequest) (runsvc.ReportAck, error) {
907
+ return s.ack, s.err
908
+ }
909
+ func (s *ackServer) HasProcessedReport(context.Context, runsvc.ID, string) (bool, error) {
910
+ return false, nil
911
+ }
912
+ func (s *ackServer) RegisterNodeSession(context.Context, runsvc.NodeRuntimeRegistration) (runsvc.NodeRuntimeRegistrationAck, error) {
913
+ panic("unreachable")
914
+ }
915
+
916
+ // Unreachable Deps stubs — the report endpoint never calls them.
917
+ func (s *ackServer) SubmitWorkflow(context.Context, []byte) (*workflow.Workflow, error) {
918
+ panic("unreachable")
919
+ }
920
+ func (s *ackServer) GetWorkflow(context.Context, string) (*workflow.Workflow, error) {
921
+ panic("unreachable")
922
+ }
923
+ func (s *ackServer) ListWorkflows(context.Context) ([]*workflow.Workflow, error) {
924
+ panic("unreachable")
925
+ }
926
+ func (s *ackServer) RemoveWorkflow(context.Context, string) error { panic("unreachable") }
927
+ func (s *ackServer) ListRuns(context.Context, runsvc.Filter) ([]runsvc.Run, error) {
928
+ panic("unreachable")
929
+ }
930
+ func (s *ackServer) GetRunByTicket(context.Context, string) (runsvc.Run, error) {
931
+ panic("unreachable")
932
+ }
933
+ func (s *ackServer) CancelRun(context.Context, string, string) error { panic("unreachable") }
934
+ func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
935
+ panic("unreachable")
936
+ }
937
+ func (s *ackServer) TaskFields(context.Context) ([]string, error) { panic("unreachable") }
938
+ func (s *ackServer) RegisterRepo(context.Context, repo.RegisterInput) (repo.Info, error) {
939
+ panic("unreachable")
940
+ }
941
+ func (s *ackServer) ListRepos(context.Context) ([]repo.Info, error) { panic("unreachable") }
942
+ func (s *ackServer) GetRepo(context.Context, string) (repo.Info, error) {
943
+ panic("unreachable")
944
+ }
945
+ func (s *ackServer) RemoveRepo(context.Context, string) error { panic("unreachable") }
946
+ func (s *ackServer) Shutdown(context.Context) error { panic("unreachable") }
947
+
948
+ // serveAck starts a thin server.New(deps) http.Handler on the relay-flow
949
+ // Unix socket inside home, returning the canned report ack/error (seam d).
950
+ func serveAck(t *testing.T, home string, ack runsvc.ReportAck, ackErr error) {
951
+ t.Helper()
952
+ root := filepath.Join(home, ".relay-flow")
953
+ if err := os.MkdirAll(root, 0700); err != nil {
954
+ t.Fatal(err)
955
+ }
956
+ sock := filepath.Join(root, "server.sock")
957
+ ln, err := net.Listen("unix", sock)
958
+ if err != nil {
959
+ t.Fatal(err)
960
+ }
961
+ h := server.New(&ackServer{ack: ack, err: ackErr})
962
+ srv := &http.Server{Handler: h}
963
+ go srv.Serve(ln)
964
+ t.Cleanup(func() {
965
+ _ = srv.Shutdown(context.Background())
966
+ _ = os.Remove(sock)
967
+ })
968
+ }