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,464 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/repo"
15
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
16
+ "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/server"
18
+ "github.com/rajpopat27/relay-flow/internal/workflow"
19
+ )
20
+
21
+ // 3.36 command surface, 3.31 init, 3.22 report exit codes. Settled seam (e):
22
+ // a thin run(args, stdin) int CLI entry in cmd/relay-flow, driven in-process
23
+ // with a temp relay-flow root. Red until 4.15 implements the parser.
24
+
25
+ // cli invokes the parser entry with args and stdin against a temp home.
26
+ func cli(t *testing.T, home string, stdin string, args ...string) (code int) {
27
+ t.Helper()
28
+ root := filepath.Join(home, ".relay-flow")
29
+ t.Setenv("RELAY_FLOW_HOME", root)
30
+ return run(args, strings.NewReader(stdin))
31
+ }
32
+
33
+ func TestCommandSurfaceExists(t *testing.T) {
34
+ home := t.TempDir()
35
+ commands := [][]string{
36
+ {"init"}, {"serve", "--recover"}, {"stop"}, {"report"},
37
+ {"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
38
+ {"workflow", "list"}, {"workflow", "get", "--name", "x"},
39
+ {"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
40
+ {"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"},
41
+ }
42
+ for _, argv := range commands {
43
+ // Recognized commands do not exit 2 ("usage/unknown"); they may exit
44
+ // 0 or 1 (server/validation) depending on environment.
45
+ if code := cli(t, home, "", argv...); code == 2 {
46
+ t.Fatalf("command %v not recognized (exit 2)", argv)
47
+ }
48
+ }
49
+ }
50
+
51
+ func TestUnknownFlagExits2(t *testing.T) {
52
+ if code := cli(t, t.TempDir(), "", "workflow", "list", "--bogus"); code != 2 {
53
+ t.Fatalf("unknown flag exit = %d, want 2", code)
54
+ }
55
+ }
56
+
57
+ // Required-flag enforcement: commands that take an identifier exit 2 (usage)
58
+ // when it is missing, before any server contact.
59
+ func TestRequiredFlagMissingExits2(t *testing.T) {
60
+ home := t.TempDir()
61
+ for _, argv := range [][]string{
62
+ {"workflow", "submit"}, // missing --file
63
+ {"workflow", "remove"}, // missing --name
64
+ {"workflow", "get"}, // missing --name
65
+ {"repo", "remove"}, // missing --name
66
+ {"repo", "get"}, // missing --name
67
+ {"run", "get"}, // missing --ticket
68
+ {"run", "cancel"}, // missing --ticket
69
+ } {
70
+ if code := cli(t, home, "", argv...); code != 2 {
71
+ t.Fatalf("%v with missing required flag exit = %d, want 2", argv, code)
72
+ }
73
+ }
74
+ }
75
+
76
+ func TestRunListRowShowsLifecycleAndActiveRetry(t *testing.T) {
77
+ next := time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC)
78
+ r := runsvc.Run{
79
+ ID: "payments/basicFlow/PAY-101", Workflow: "basicFlow", State: runsvc.StateStarting,
80
+ Retry: &runsvc.RetryStatus{Attempt: 3, LastError: "jira unavailable", NextRetryAt: next},
81
+ }
82
+ got := formatRunListRow(r)
83
+ for _, want := range []string{"starting", "retrying", "attempt=3", "next=2026-08-25T12:00:00Z", `error="jira unavailable"`} {
84
+ if !strings.Contains(got, want) {
85
+ t.Fatalf("run list row %q missing %q", got, want)
86
+ }
87
+ }
88
+ }
89
+
90
+ // initHome seeds a temp relay-flow home via the real init path so serve
91
+ // has a valid machine config and database (normal serve refuses to start
92
+ // without them per 5.5).
93
+ func initHome(t *testing.T, home string) {
94
+ t.Helper()
95
+ if code := cli(t, home, "jira\norca\nopencode\n", "init"); code != 0 {
96
+ t.Fatalf("init exit = %d, want 0", code)
97
+ }
98
+ }
99
+
100
+ // 3.29 (lock): serve creates server.lock owner-only. Asserted through the
101
+ // serve fixture.
102
+ func TestServerLockIsOwnerOnly(t *testing.T) {
103
+ home := t.TempDir()
104
+ initHome(t, home)
105
+ // Start serve in the background of the test via the parser entry; it
106
+ // creates the flock file then blocks. We assert the lock file's mode.
107
+ go cli(t, home, "", "serve")
108
+ lock := filepath.Join(home, ".relay-flow", "server.lock")
109
+ var fi os.FileInfo
110
+ var err error
111
+ deadline := time.Now().Add(5 * time.Second)
112
+ for time.Now().Before(deadline) {
113
+ fi, err = os.Stat(lock)
114
+ if err == nil {
115
+ break
116
+ }
117
+ time.Sleep(20 * time.Millisecond)
118
+ }
119
+ if err != nil {
120
+ t.Fatalf("server.lock not created: %v", err)
121
+ }
122
+ if fi.Mode().Perm() != 0600 {
123
+ t.Fatalf("server.lock mode = %o, want 0600", fi.Mode().Perm())
124
+ }
125
+ }
126
+
127
+ // 3.29 (socket): the serve startup path owns server.sock creation and chmods
128
+ // it 0600. Asserted through the serve fixture (the component that binds the
129
+ // socket), not a test-created listener.
130
+ func TestServerSocketIsOwnerOnly(t *testing.T) {
131
+ home := t.TempDir()
132
+ initHome(t, home)
133
+ go cli(t, home, "", "serve")
134
+ sock := filepath.Join(home, ".relay-flow", "server.sock")
135
+ var fi os.FileInfo
136
+ var err error
137
+ deadline := time.Now().Add(5 * time.Second)
138
+ for time.Now().Before(deadline) {
139
+ fi, err = os.Stat(sock)
140
+ if err == nil {
141
+ break
142
+ }
143
+ time.Sleep(20 * time.Millisecond)
144
+ }
145
+ if err != nil {
146
+ t.Fatalf("server.sock not created by serve: %v", err)
147
+ }
148
+ if fi.Mode().Perm() != 0600 {
149
+ t.Fatalf("server.sock mode = %o, want 0600", fi.Mode().Perm())
150
+ }
151
+ }
152
+
153
+ func TestReportReadsOneJSONObjectFromStdin(t *testing.T) {
154
+ if code := cli(t, t.TempDir(), "{not json", "report"); code != 1 {
155
+ t.Fatalf("malformed report JSON exit = %d, want 1", code)
156
+ }
157
+ }
158
+
159
+ // 3.22: report ack semantics — exit 0 on any ack (incl. stale/duplicate),
160
+ // 1 on server/validation failure, never non-zero for a stale/duplicate.
161
+ // Drives the real CLI report against an in-process server (seam d) over the
162
+ // relay-flow Unix socket in the temp home.
163
+ func TestReportAckMatrix(t *testing.T) {
164
+ 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"}}}`
165
+
166
+ // Any ack (accepted fresh, or accepted duplicate/stale) exits 0.
167
+ for name, ack := range map[string]runsvc.ReportAck{
168
+ "accepted fresh": {Accepted: true, Duplicate: false},
169
+ "accepted duplicate": {Accepted: true, Duplicate: true},
170
+ } {
171
+ home := t.TempDir()
172
+ serveAck(t, home, ack, nil) // seam d server on the home socket
173
+ if code := cli(t, home, valid, "report"); code != 0 {
174
+ t.Fatalf("%s: exit = %d, want 0", name, code)
175
+ }
176
+ }
177
+
178
+ // Server/validation failure exits 1.
179
+ home := t.TempDir()
180
+ serveAck(t, home, runsvc.ReportAck{}, errReportInvalid)
181
+ if code := cli(t, home, valid, "report"); code != 1 {
182
+ t.Fatalf("validation failure exit = %d, want 1", code)
183
+ }
184
+ }
185
+
186
+ func TestReportUnreachableServerExits1(t *testing.T) {
187
+ 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"}}}`
188
+ if code := cli(t, t.TempDir(), valid, "report"); code != 1 {
189
+ t.Fatalf("unreachable server exit = %d, want 1", code)
190
+ }
191
+ }
192
+
193
+ func TestInitRefusesToOverwrite(t *testing.T) {
194
+ home := t.TempDir()
195
+ // init reads the three plugin selections from stdin.
196
+ if code := cli(t, home, "jira\norca\nopencode\n", "init"); code != 0 {
197
+ t.Fatalf("first init exit = %d, want 0", code)
198
+ }
199
+ cfgPath := filepath.Join(home, ".relay-flow", "config.yaml")
200
+ dbPath := filepath.Join(home, ".relay-flow", "state.db")
201
+ cfg := readFile(t, cfgPath)
202
+ for _, want := range []string{
203
+ "taskPlugin:", "runnerPlugin:", "harnessPlugin:",
204
+ "keepTerminalsAlive: true", "keepSessionsAlive: true",
205
+ } {
206
+ if !strings.Contains(cfg, want) {
207
+ t.Fatalf("config missing %q:\n%s", want, cfg)
208
+ }
209
+ }
210
+ dbBefore := readFile(t, dbPath)
211
+ // state.db is a REAL SQLite database, not an arbitrary non-empty file:
212
+ // the first 16 bytes are the SQLite format-3 magic header.
213
+ if !strings.HasPrefix(dbBefore, "SQLite format 3\x00") {
214
+ t.Fatalf("state.db is not a SQLite database (missing magic header); got %q", dbBefore[:min(16, len(dbBefore))])
215
+ }
216
+
217
+ if code := cli(t, home, "jira\norca\nopencode\n", "init"); code == 0 {
218
+ t.Fatal("init overwrote existing config/history")
219
+ }
220
+ if readFile(t, cfgPath) != cfg {
221
+ t.Fatal("init changed existing machine config")
222
+ }
223
+ if readFile(t, dbPath) != dbBefore {
224
+ t.Fatal("init changed existing execution history")
225
+ }
226
+ }
227
+
228
+ // 8.2: --task-plugin/--runner-plugin/--harness-plugin run init without any
229
+ // prompt/stdin and write the same machine config as the stdin path.
230
+ func TestInitFlagsNonInteractive(t *testing.T) {
231
+ home := t.TempDir()
232
+ // Flags fully replace stdin: empty stdin must still succeed.
233
+ if code := cli(t, home, "", "init",
234
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode"); code != 0 {
235
+ t.Fatalf("flagged init exit = %d, want 0", code)
236
+ }
237
+ cfgFlags := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml"))
238
+
239
+ home2 := t.TempDir()
240
+ if code := cli(t, home2, "jira\norca\nopencode\n", "init"); code != 0 {
241
+ t.Fatalf("stdin init exit = %d, want 0", code)
242
+ }
243
+ cfgStdin := readFile(t, filepath.Join(home2, ".relay-flow", "config.yaml"))
244
+ if cfgFlags != cfgStdin {
245
+ t.Fatalf("flagged vs stdin config differ:\nflags:\n%s\nstdin:\n%s", cfgFlags, cfgStdin)
246
+ }
247
+
248
+ // Partial flags are a usage error and must not write config.
249
+ home3 := t.TempDir()
250
+ if code := cli(t, home3, "", "init", "--task-plugin", "jira"); code != 2 {
251
+ t.Fatalf("partial flags exit = %d, want 2", code)
252
+ }
253
+ if _, err := os.Stat(filepath.Join(home3, ".relay-flow", "config.yaml")); !os.IsNotExist(err) {
254
+ t.Fatal("partial-flag init wrote config")
255
+ }
256
+ }
257
+
258
+ func readFile(t *testing.T, path string) string {
259
+ t.Helper()
260
+ b, err := os.ReadFile(path)
261
+ if err != nil {
262
+ t.Fatalf("read %s: %v", path, err)
263
+ }
264
+ return string(b)
265
+ }
266
+
267
+ func min(a, b int) int {
268
+ if a < b {
269
+ return a
270
+ }
271
+ return b
272
+ }
273
+
274
+ // 8.4: fully-flagged repo register never prompts and produces the same
275
+ // repo entry the interactive run would post.
276
+ type registerServer struct {
277
+ ackServer // embed for the unreachable stubs
278
+ fields []string
279
+ gotInput *repo.RegisterInput
280
+ calls int
281
+ }
282
+
283
+ func (s *registerServer) TaskFields(context.Context) ([]string, error) {
284
+ s.calls++
285
+ return s.fields, nil
286
+ }
287
+ func (s *registerServer) RegisterRepo(_ context.Context, in repo.RegisterInput) (repo.Info, error) {
288
+ s.calls++
289
+ cp := in
290
+ s.gotInput = &cp
291
+ return repo.Info{Name: in.Name, Path: in.Path, TaskConfig: in.TaskConfig}, nil
292
+ }
293
+
294
+ func serveRegister(t *testing.T, home string, fields []string) *registerServer {
295
+ t.Helper()
296
+ root := filepath.Join(home, ".relay-flow")
297
+ if err := os.MkdirAll(root, 0700); err != nil {
298
+ t.Fatal(err)
299
+ }
300
+ sock := filepath.Join(root, "server.sock")
301
+ ln, err := net.Listen("unix", sock)
302
+ if err != nil {
303
+ t.Fatal(err)
304
+ }
305
+ deps := &registerServer{fields: fields}
306
+ srv := &http.Server{Handler: server.New(deps)}
307
+ go srv.Serve(ln)
308
+ t.Cleanup(func() {
309
+ _ = srv.Shutdown(context.Background())
310
+ _ = os.Remove(sock)
311
+ })
312
+ return deps
313
+ }
314
+
315
+ func TestRepoRegisterFlagsNonInteractive(t *testing.T) {
316
+ home := t.TempDir()
317
+ deps := serveRegister(t, home, []string{"project", "component"})
318
+
319
+ // Fully-flagged run: no stdin, no TTY — must not prompt.
320
+ code := cli(t, home, "", "repo", "register",
321
+ "--name", "payments", "--path", "/srv/payments",
322
+ "--set", "project=PAY", "--set", "component=core")
323
+ if code != 0 {
324
+ t.Fatalf("fully-flagged register exit = %d, want 0", code)
325
+ }
326
+ if deps.gotInput == nil {
327
+ t.Fatal("server never received registration")
328
+ }
329
+ if deps.gotInput.Name != "payments" || deps.gotInput.Path != "/srv/payments" {
330
+ t.Fatalf("name/path = %q/%q", deps.gotInput.Name, deps.gotInput.Path)
331
+ }
332
+ if deps.gotInput.TaskConfig["project"] != "PAY" || deps.gotInput.TaskConfig["component"] != "core" {
333
+ t.Fatalf("taskConfig = %v", deps.gotInput.TaskConfig)
334
+ }
335
+
336
+ // Missing a required key is a usage error; only the TaskFields lookup
337
+ // is allowed to hit the server (RegisterRepo must not be called).
338
+ home2 := t.TempDir()
339
+ deps2 := serveRegister(t, home2, []string{"project", "component"})
340
+ if code := cli(t, home2, "", "repo", "register",
341
+ "--name", "x", "--path", "/x", "--set", "project=PAY"); code != 2 {
342
+ t.Fatalf("missing required key exit = %d, want 2", code)
343
+ }
344
+ if deps2.gotInput != nil {
345
+ t.Fatal("RegisterRepo called despite missing required key")
346
+ }
347
+
348
+ // Missing --name/--path must exit 2 with ZERO server contact.
349
+ home3 := t.TempDir()
350
+ deps3 := serveRegister(t, home3, []string{"project"})
351
+ for _, argv := range [][]string{
352
+ {"repo", "register", "--path", "/x", "--set", "project=PAY"},
353
+ {"repo", "register", "--name", "x", "--set", "project=PAY"},
354
+ {"repo", "register", "--set", "project=PAY"},
355
+ } {
356
+ before := deps3.calls
357
+ if code := cli(t, home3, "", argv...); code != 2 {
358
+ t.Fatalf("%v exit = %d, want 2", argv, code)
359
+ }
360
+ if deps3.calls != before {
361
+ t.Fatalf("%v contacted server (%d calls)", argv, deps3.calls-before)
362
+ }
363
+ }
364
+
365
+ // Invalid --set forms are usage errors at parse time: no server contact.
366
+ home4 := t.TempDir()
367
+ deps4 := serveRegister(t, home4, []string{"project"})
368
+ for _, argv := range [][]string{
369
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "noequals"},
370
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "=v"},
371
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "project="},
372
+ {"repo", "register", "--name", "x", "--path", "/x", "--set", "project=A", "--set", "project=B"},
373
+ } {
374
+ before := deps4.calls
375
+ if code := cli(t, home4, "", argv...); code != 2 {
376
+ t.Fatalf("%v exit = %d, want 2", argv, code)
377
+ }
378
+ if deps4.calls != before {
379
+ t.Fatalf("%v contacted server", argv)
380
+ }
381
+ }
382
+
383
+ // Unknown --set keys are usage errors (flags map exactly to required keys).
384
+ home5 := t.TempDir()
385
+ serveRegister(t, home5, []string{"project"})
386
+ if code := cli(t, home5, "", "repo", "register",
387
+ "--name", "x", "--path", "/x",
388
+ "--set", "project=PAY", "--set", "bogus=v"); code != 2 {
389
+ t.Fatalf("unknown key exit = %d, want 2", code)
390
+ }
391
+ }
392
+
393
+ var errReportInvalid = errors.New("invalid report")
394
+
395
+ // ackServer is the minimal server.Deps implementation for the report path.
396
+ // Only SubmitReport is exercised; all other methods are unreachable stubs.
397
+ type ackServer struct {
398
+ ack runsvc.ReportAck
399
+ err error
400
+ }
401
+
402
+ func (s *ackServer) SubmitReport(context.Context, runsvc.ReportRequest) (runsvc.ReportAck, error) {
403
+ return s.ack, s.err
404
+ }
405
+ func (s *ackServer) HasProcessedReport(context.Context, runsvc.ID, string) (bool, error) {
406
+ return false, nil
407
+ }
408
+ func (s *ackServer) RegisterNodeSession(context.Context, runsvc.NodeRuntimeRegistration) (runsvc.NodeRuntimeRegistrationAck, error) {
409
+ panic("unreachable")
410
+ }
411
+
412
+ // Unreachable Deps stubs — the report endpoint never calls them.
413
+ func (s *ackServer) SubmitWorkflow(context.Context, []byte) (*workflow.Workflow, error) {
414
+ panic("unreachable")
415
+ }
416
+ func (s *ackServer) GetWorkflow(context.Context, string) (*workflow.Workflow, error) {
417
+ panic("unreachable")
418
+ }
419
+ func (s *ackServer) ListWorkflows(context.Context) ([]*workflow.Workflow, error) {
420
+ panic("unreachable")
421
+ }
422
+ func (s *ackServer) RemoveWorkflow(context.Context, string) error { panic("unreachable") }
423
+ func (s *ackServer) ListRuns(context.Context, runsvc.Filter) ([]runsvc.Run, error) {
424
+ panic("unreachable")
425
+ }
426
+ func (s *ackServer) GetRunByTicket(context.Context, string) (runsvc.Run, error) {
427
+ panic("unreachable")
428
+ }
429
+ func (s *ackServer) CancelRun(context.Context, string, string) error { panic("unreachable") }
430
+ func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
431
+ panic("unreachable")
432
+ }
433
+ func (s *ackServer) TaskFields(context.Context) ([]string, error) { panic("unreachable") }
434
+ func (s *ackServer) RegisterRepo(context.Context, repo.RegisterInput) (repo.Info, error) {
435
+ panic("unreachable")
436
+ }
437
+ func (s *ackServer) ListRepos(context.Context) ([]repo.Info, error) { panic("unreachable") }
438
+ func (s *ackServer) GetRepo(context.Context, string) (repo.Info, error) {
439
+ panic("unreachable")
440
+ }
441
+ func (s *ackServer) RemoveRepo(context.Context, string) error { panic("unreachable") }
442
+ func (s *ackServer) Shutdown(context.Context) error { panic("unreachable") }
443
+
444
+ // serveAck starts a thin server.New(deps) http.Handler on the relay-flow
445
+ // Unix socket inside home, returning the canned report ack/error (seam d).
446
+ func serveAck(t *testing.T, home string, ack runsvc.ReportAck, ackErr error) {
447
+ t.Helper()
448
+ root := filepath.Join(home, ".relay-flow")
449
+ if err := os.MkdirAll(root, 0700); err != nil {
450
+ t.Fatal(err)
451
+ }
452
+ sock := filepath.Join(root, "server.sock")
453
+ ln, err := net.Listen("unix", sock)
454
+ if err != nil {
455
+ t.Fatal(err)
456
+ }
457
+ h := server.New(&ackServer{ack: ack, err: ackErr})
458
+ srv := &http.Server{Handler: h}
459
+ go srv.Serve(ln)
460
+ t.Cleanup(func() {
461
+ _ = srv.Shutdown(context.Background())
462
+ _ = os.Remove(sock)
463
+ })
464
+ }