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
@@ -1,250 +1,740 @@
1
- // Command relay-flow automates tracker runner agent workflows.
2
- // `serve` is a central process hosting any number of workflows submitted via
3
- // `submit`. `report` is a one-shot socket client (invoked by the opencode
4
- // plugin) that asks the server to record an agent outcome.
1
+ // Command relay-flow is the entrypoint. It is a thin command parser
2
+ // (standard flag package) with zero business logic: every command
3
+ // delegates to the server client (internal/server.Client) or the
4
+ // section-5 composition root for init/serve. The testable entry is
5
+ // run(args, stdin) int (allowed seam e).
5
6
  package main
6
7
 
7
8
  import (
9
+ "bufio"
10
+ "context"
8
11
  "encoding/json"
9
12
  "flag"
10
13
  "fmt"
11
14
  "io"
12
- "log"
13
- "net"
14
15
  "os"
15
- "os/exec"
16
16
  "os/signal"
17
17
  "path/filepath"
18
-
18
+ "strings"
19
19
  "syscall"
20
+ "time"
20
21
 
21
- "github.com/rajpopat27/relay-flow/internal/acli"
22
+ "github.com/charmbracelet/huh"
23
+ "github.com/mattn/go-isatty"
22
24
  "github.com/rajpopat27/relay-flow/internal/config"
23
- "github.com/rajpopat27/relay-flow/internal/discovery"
25
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
26
+ "github.com/rajpopat27/relay-flow/internal/harness"
27
+ "github.com/rajpopat27/relay-flow/internal/logging"
28
+ "github.com/rajpopat27/relay-flow/internal/paths"
29
+ "github.com/rajpopat27/relay-flow/internal/repo"
30
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
31
+ "github.com/rajpopat27/relay-flow/internal/runner"
24
32
  "github.com/rajpopat27/relay-flow/internal/server"
33
+ "github.com/rajpopat27/relay-flow/internal/task"
34
+
35
+ // Adapter registrations (factories registered via init for plugin
36
+ // name validation at init-time).
37
+ _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
38
+ _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
39
+ _ "github.com/rajpopat27/relay-flow/internal/task/jira"
25
40
  )
26
41
 
27
- func main() {
28
- if len(os.Args) < 2 {
29
- usage()
30
- os.Exit(1)
42
+ // Exit codes per specs/workflow-repo-management "CLI exit codes are
43
+ // stable": 0 success, 2 command/flag usage, 1 server/validation/operation.
44
+ const (
45
+ exitOK = 0
46
+ exitFail = 1
47
+ exitUsage = 2
48
+ )
49
+
50
+ func main() { os.Exit(run(os.Args[1:], os.Stdin)) }
51
+
52
+ // home returns the relay-flow root, honoring RELAY_FLOW_HOME (tests and
53
+ // non-standard installs) and falling back to ~/.relay-flow.
54
+ func home() (paths.Paths, error) {
55
+ if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
56
+ root := env
57
+ return paths.Paths{
58
+ Root: root,
59
+ Config: filepath.Join(root, "config.yaml"),
60
+ Workflows: filepath.Join(root, "workflows"),
61
+ Database: filepath.Join(root, "state.db"),
62
+ Socket: filepath.Join(root, "server.sock"),
63
+ Lock: filepath.Join(root, "server.lock"),
64
+ ServerLog: filepath.Join(root, "server.log"),
65
+ PluginLog: filepath.Join(root, "plugin.log"),
66
+ }, nil
31
67
  }
32
- switch os.Args[1] {
68
+ return paths.ForUserHome()
69
+ }
70
+
71
+ // run parses args and dispatches one command. It is the test seam: stdin
72
+ // is injectable so the report command can be driven in-process.
73
+ func run(args []string, stdin io.Reader) int {
74
+ if len(args) == 0 {
75
+ usage(os.Stderr)
76
+ return exitUsage
77
+ }
78
+ p, err := home()
79
+ if err != nil {
80
+ fmt.Fprintln(os.Stderr, err)
81
+ return exitFail
82
+ }
83
+ client := server.NewClient(p.Socket)
84
+
85
+ switch args[0] {
33
86
  case "init":
34
- cmdInit(os.Args[2:])
35
- case "stop":
36
- cmdStop(os.Args[2:])
87
+ return cmdInit(p, args[1:], stdin)
37
88
  case "serve":
38
- cmdServe(os.Args[2:])
39
- case "submit":
40
- cmdSubmit(os.Args[2:])
89
+ return cmdServe(p, args[1:])
90
+ case "stop":
91
+ fs := flag.NewFlagSet("stop", flag.ContinueOnError)
92
+ if err := fs.Parse(args[1:]); err != nil {
93
+ return exitUsage
94
+ }
95
+ if err := client.Stop(context.Background()); err != nil {
96
+ fmt.Fprintln(os.Stderr, err)
97
+ return exitFail
98
+ }
99
+ return exitOK
41
100
  case "report":
42
- cmdReport(os.Args[2:])
101
+ fs := flag.NewFlagSet("report", flag.ContinueOnError)
102
+ if err := fs.Parse(args[1:]); err != nil {
103
+ return exitUsage
104
+ }
105
+ return cmdReport(client, stdin)
106
+ case "runtime-register":
107
+ fs := flag.NewFlagSet("runtime-register", flag.ContinueOnError)
108
+ if err := fs.Parse(args[1:]); err != nil {
109
+ return exitUsage
110
+ }
111
+ return cmdRuntimeRegister(client, stdin)
112
+ case "workflow":
113
+ return cmdWorkflow(client, args[1:])
114
+ case "repo":
115
+ return cmdRepo(client, args[1:], stdin)
116
+ case "run":
117
+ return cmdRun(client, args[1:])
43
118
  default:
44
- usage()
45
- os.Exit(1)
119
+ usage(os.Stderr)
120
+ return exitUsage
46
121
  }
47
122
  }
48
123
 
49
- func usage() {
50
- fmt.Fprintln(os.Stderr, "usage: relay-flow init --assignee \"<your Jira display name or accountId>\"")
51
- fmt.Fprintln(os.Stderr, " relay-flow stop serve")
52
- fmt.Fprintln(os.Stderr, " relay-flow serve [--dry-run] [--foreground]")
53
- fmt.Fprintln(os.Stderr, " relay-flow submit [-f <yaml>] (workflow name comes from the YAML's name field)")
54
- fmt.Fprintln(os.Stderr, " relay-flow report --workflow <name> --ticket <key> --node <node> --outcome <success|failure> --summary <text>")
55
- fmt.Fprintln(os.Stderr, " server artifacts (lock/sock/log) are always under ~/.relay-flow/")
124
+ func usage(w io.Writer) {
125
+ fmt.Fprintln(w, `relay-flow durable ticket runner
126
+
127
+ Usage:
128
+ relay-flow init [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
129
+ relay-flow serve [--recover] [--debug]
130
+ relay-flow stop
131
+ relay-flow report
132
+
133
+ relay-flow workflow submit --file <path>
134
+ relay-flow workflow remove --name <name>
135
+ relay-flow workflow list
136
+ relay-flow workflow get --name <name>
137
+
138
+ relay-flow repo register [--name <name> --path <path> --set key=value ...]
139
+ relay-flow repo remove --name <name>
140
+ relay-flow repo list
141
+ relay-flow repo get --name <name>
142
+
143
+ relay-flow run list
144
+ relay-flow run get --ticket <key>
145
+ relay-flow run cancel --ticket <key>`)
56
146
  }
57
147
 
58
- // daemonize re-execs the binary detached with childArgs, log file attached
59
- // as stdout+stderr. No supervisor, no IPC: the child IS the worker. Returns
60
- // after spawning.
61
- func daemonize(logPath string, childArgs ...string) {
62
- f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
63
- if err != nil {
64
- log.Fatalf("open log file: %v", err)
148
+ // --- init / serve delegate to the section-5 composition root ---
149
+
150
+ // cmdInit is the init composition root (docs lines 950/1028): select the
151
+ // three plugin names, atomically write the machine config, and initialize
152
+ // the SQLite database. Refuses to overwrite existing config or history.
153
+ // Plugin selection precedence: --task-plugin/--runner-plugin/--harness-plugin
154
+ // flags (all three required for a fully non-interactive run) → huh form on
155
+ // a TTY → three stdin lines (task, runner, harness), the documented test
156
+ // seam and script path.
157
+ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
158
+ fs := flag.NewFlagSet("init", flag.ContinueOnError)
159
+ taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
160
+ runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
161
+ harnessName := fs.String("harness-plugin", "", "harness plugin name (non-interactive)")
162
+ if err := fs.Parse(args); err != nil {
163
+ return exitUsage
65
164
  }
66
- self, err := os.Executable()
67
- if err != nil {
68
- log.Fatalf("resolve executable: %v", err)
165
+ flagged := *taskName != "" || *runnerName != "" || *harnessName != ""
166
+ if flagged && (*taskName == "" || *runnerName == "" || *harnessName == "") {
167
+ fmt.Fprintln(os.Stderr, "init: --task-plugin, --runner-plugin, and --harness-plugin must be given together")
168
+ return exitUsage
169
+ }
170
+ // Refuse to overwrite existing state.
171
+ if _, err := os.Stat(p.Config); err == nil {
172
+ fmt.Fprintln(os.Stderr, "init: already initialized (config exists): "+p.Config)
173
+ return exitFail
174
+ }
175
+ if _, err := os.Stat(p.Database); err == nil {
176
+ fmt.Fprintln(os.Stderr, "init: already initialized (database exists): "+p.Database)
177
+ return exitFail
178
+ }
179
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
180
+ fmt.Fprintln(os.Stderr, err)
181
+ return exitFail
182
+ }
183
+ _ = os.Chmod(p.Root, 0o700)
184
+
185
+ // Selection precedence: flags → TTY form → stdin lines. The stdin path
186
+ // is the documented test seam and script path.
187
+ var names []string
188
+ switch {
189
+ case flagged:
190
+ names = []string{*taskName, *runnerName, *harnessName}
191
+ case isTTY(stdin):
192
+ var err error
193
+ names, err = pickPluginsInteractive()
194
+ if err != nil {
195
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
196
+ return exitFail
197
+ }
198
+ default:
199
+ names = readPluginLines(stdin)
200
+ if names == nil {
201
+ fmt.Fprintln(os.Stderr, "init: expected three plugin selections (task, runner, harness) on stdin")
202
+ return exitFail
203
+ }
204
+ }
205
+ // Validate against the registered factories; unknown names list the
206
+ // registered set per design (no silent acceptance).
207
+ if err := task.ValidateName(names[0]); err != nil {
208
+ fmt.Fprintln(os.Stderr, "init: task plugin: "+err.Error())
209
+ return exitFail
210
+ }
211
+ if err := runner.ValidateName(names[1]); err != nil {
212
+ fmt.Fprintln(os.Stderr, "init: runner plugin: "+err.Error())
213
+ return exitFail
69
214
  }
70
- cmd := exec.Command(self, childArgs...)
71
- cmd.Stdout, cmd.Stderr = f, f
72
- cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
73
- cmd.Env = append(os.Environ(), "RELAY_FLOW_DAEMONIZED=1")
74
- if err := cmd.Start(); err != nil {
75
- log.Fatalf("daemonize: %v", err)
215
+ if err := harness.ValidateName(names[2]); err != nil {
216
+ fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
217
+ return exitFail
218
+ }
219
+
220
+ cfg := &config.Machine{
221
+ TaskPlugin: names[0],
222
+ RunnerPlugin: names[1],
223
+ HarnessPlugin: names[2],
224
+ KeepTerminalsAlive: true,
225
+ KeepSessionsAlive: true,
76
226
  }
77
- fmt.Printf("started (pid %d), logging to %s\n", cmd.Process.Pid, logPath)
227
+ if err := config.SaveMachine(p.Config, cfg); err != nil {
228
+ fmt.Fprintln(os.Stderr, err)
229
+ return exitFail
230
+ }
231
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
232
+ fmt.Fprintln(os.Stderr, err)
233
+ return exitFail
234
+ }
235
+ return exitOK
236
+ }
237
+
238
+ // isTTY reports whether stdin is an interactive terminal.
239
+ func isTTY(stdin io.Reader) bool {
240
+ f, ok := stdin.(*os.File)
241
+ return ok && isatty.IsTerminal(f.Fd())
78
242
  }
79
243
 
80
- // cmdInit writes the machine config (~/.relay-flow/config.yaml) with this machine
81
- // user's tracker identity, probe-validated against the tracker.
82
- func cmdInit(args []string) {
83
- fs := flag.NewFlagSet("init", flag.ExitOnError)
84
- assignee := fs.String("assignee", "", "your tracker display name or accountId")
85
- fs.Parse(args)
86
- if *assignee == "" {
87
- log.Fatalf("usage: relay-flow init --assignee \"<your tracker display name or accountId>\"")
244
+ // pickPluginsInteractive runs the huh form: one searchable select per
245
+ // plugin (task, runner, harness); enter advances/submits.
246
+ func pickPluginsInteractive() ([]string, error) {
247
+ names := make([]string, 3)
248
+ form := huh.NewForm(
249
+ huh.NewGroup(huh.NewSelect[string]().
250
+ Title("Task plugin").
251
+ Options(huh.NewOptions(task.Names()...)...).
252
+ Filtering(true).
253
+ Value(&names[0])),
254
+ huh.NewGroup(huh.NewSelect[string]().
255
+ Title("Runner plugin").
256
+ Options(huh.NewOptions(runner.Names()...)...).
257
+ Filtering(true).
258
+ Value(&names[1])),
259
+ huh.NewGroup(huh.NewSelect[string]().
260
+ Title("Harness plugin").
261
+ Options(huh.NewOptions(harness.Names()...)...).
262
+ Filtering(true).
263
+ Value(&names[2])),
264
+ )
265
+ if err := form.Run(); err != nil {
266
+ return nil, err
88
267
  }
89
- if err := acli.New().ValidateAssignee(*assignee); err != nil {
90
- log.Fatalf("%v", err)
268
+ return names, nil
269
+ }
270
+
271
+ // readPluginLines reads the three plugin names from stdin, one per line
272
+ // (task, runner, harness); trailing whitespace tolerated. Returns nil when
273
+ // fewer than three non-empty lines are present.
274
+ func readPluginLines(stdin io.Reader) []string {
275
+ names := make([]string, 0, 3)
276
+ scanner := bufio.NewScanner(stdin)
277
+ for len(names) < 3 && scanner.Scan() {
278
+ line := strings.TrimSpace(scanner.Text())
279
+ if line == "" {
280
+ continue
281
+ }
282
+ names = append(names, line)
91
283
  }
92
- if err := (&config.MachineConfig{Assignee: *assignee}).Save(); err != nil {
93
- log.Fatalf("%v", err)
284
+ if len(names) != 3 {
285
+ return nil
94
286
  }
95
- p, _ := config.MachineConfigPath()
96
- fmt.Printf("machine config written to %s (assignee=%q)\n", p, *assignee)
287
+ return names
97
288
  }
98
289
 
99
- func cmdStop(args []string) {
100
- // `stop serve` asks the central server to shut down over its socket;
101
- // process exit releases the flock, so no pid file exists to clean up.
102
- if len(args) != 1 || args[0] != "serve" {
103
- log.Fatalf("usage: relay-flow stop serve")
290
+ func cmdServe(p paths.Paths, args []string) int {
291
+ fs := flag.NewFlagSet("serve", flag.ContinueOnError)
292
+ recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
293
+ debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
294
+ if err := fs.Parse(args); err != nil {
295
+ return exitUsage
104
296
  }
105
- client, err := server.NewClient()
297
+ logCloser, err := logging.Setup(p.ServerLog, logging.Options{
298
+ Debug: *debug,
299
+ Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
300
+ })
106
301
  if err != nil {
107
- log.Fatalf("%v", err)
302
+ fmt.Fprintln(os.Stderr, err)
303
+ return exitFail
108
304
  }
109
- if err := client.Shutdown(); err != nil {
110
- log.Fatalf("%v", err)
305
+ defer logCloser.Close()
306
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
307
+ defer stop()
308
+ if err := serveRoot(ctx, p, *recover); err != nil {
309
+ fmt.Fprintln(os.Stderr, err)
310
+ return exitFail
111
311
  }
112
- fmt.Println("server stopped")
312
+ return exitOK
113
313
  }
114
314
 
115
- // cmdServe runs the central server: one process hosting any number of
116
- // workflows submitted over its unix socket. Daemonizes by default.
117
- func cmdServe(args []string) {
118
- fs := flag.NewFlagSet("serve", flag.ExitOnError)
119
- dryRun := fs.Bool("dry-run", false, "log every runner command instead of executing it")
120
- foreground := fs.Bool("foreground", false, "stay in the foreground")
121
- fs.Parse(args)
315
+ // --- report ---
122
316
 
123
- home, err := os.UserHomeDir()
317
+ // cmdReport reads one JSON object from stdin and posts it to /reports.
318
+ // Exit 0 on any ack (including duplicate/stale), 1 on server/validation
319
+ // failure or malformed JSON.
320
+ func cmdReport(c *server.Client, stdin io.Reader) int {
321
+ body, err := io.ReadAll(stdin)
124
322
  if err != nil {
125
- log.Fatalf("%v", err)
323
+ fmt.Fprintln(os.Stderr, err)
324
+ return exitFail
325
+ }
326
+ var req runsvc.ReportRequest
327
+ if err := json.Unmarshal(body, &req); err != nil {
328
+ fmt.Fprintln(os.Stderr, "report: malformed JSON: "+err.Error())
329
+ return exitFail
126
330
  }
127
- logPath := filepath.Join(home, ".relay-flow", "server.log")
331
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
332
+ defer cancel()
333
+ if _, err := c.SubmitReport(ctx, req); err != nil {
334
+ fmt.Fprintln(os.Stderr, err)
335
+ return exitFail
336
+ }
337
+ return exitOK
338
+ }
128
339
 
129
- if !*foreground {
130
- // Acquire the single-instance lock in the PARENT, before spawning:
131
- // this is where the user is watching, so "already running" must
132
- // fail here, not silently in the detached child.
133
- release, err := discovery.AcquireServerLock()
340
+ // cmdRuntimeRegister reads one plugin-owned JSON session registration from
341
+ // stdin and forwards it to the server over the existing Unix socket.
342
+ func cmdRuntimeRegister(c *server.Client, stdin io.Reader) int {
343
+ body, err := io.ReadAll(stdin)
344
+ if err != nil {
345
+ fmt.Fprintln(os.Stderr, err)
346
+ return exitFail
347
+ }
348
+ var registration runsvc.NodeRuntimeRegistration
349
+ if err := json.Unmarshal(body, &registration); err != nil {
350
+ fmt.Fprintln(os.Stderr, "runtime-register: malformed JSON: "+err.Error())
351
+ return exitFail
352
+ }
353
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
354
+ defer cancel()
355
+ if _, err := c.RegisterNodeSession(ctx, registration); err != nil {
356
+ fmt.Fprintln(os.Stderr, err)
357
+ return exitFail
358
+ }
359
+ return exitOK
360
+ }
361
+
362
+ // --- workflow ---
363
+
364
+ func cmdWorkflow(c *server.Client, args []string) int {
365
+ if len(args) == 0 {
366
+ usage(os.Stderr)
367
+ return exitUsage
368
+ }
369
+ switch args[0] {
370
+ case "submit":
371
+ fs := flag.NewFlagSet("workflow submit", flag.ContinueOnError)
372
+ file := fs.String("file", "", "workflow YAML file")
373
+ if err := fs.Parse(args[1:]); err != nil {
374
+ return exitUsage
375
+ }
376
+ if *file == "" {
377
+ fmt.Fprintln(os.Stderr, "workflow submit: --file is required")
378
+ return exitUsage
379
+ }
380
+ body, err := os.ReadFile(*file)
381
+ if err != nil {
382
+ fmt.Fprintln(os.Stderr, err)
383
+ return exitFail
384
+ }
385
+ wf, err := c.SubmitWorkflow(context.Background(), body)
134
386
  if err != nil {
135
- log.Fatalf("%v", err)
387
+ fmt.Fprintln(os.Stderr, err)
388
+ return exitFail
389
+ }
390
+ fmt.Println(wf.Name)
391
+ return exitOK
392
+ case "remove":
393
+ fs := flag.NewFlagSet("workflow remove", flag.ContinueOnError)
394
+ name := fs.String("name", "", "workflow name")
395
+ if err := fs.Parse(args[1:]); err != nil {
396
+ return exitUsage
136
397
  }
137
- release()
138
- childArgs := []string{"serve", "--foreground"}
139
- if *dryRun {
140
- childArgs = append(childArgs, "--dry-run")
398
+ if *name == "" {
399
+ fmt.Fprintln(os.Stderr, "workflow remove: --name is required")
400
+ return exitUsage
401
+ }
402
+ if err := c.RemoveWorkflow(context.Background(), *name); err != nil {
403
+ fmt.Fprintln(os.Stderr, err)
404
+ return exitFail
405
+ }
406
+ return exitOK
407
+ case "list":
408
+ fs := flag.NewFlagSet("workflow list", flag.ContinueOnError)
409
+ if err := fs.Parse(args[1:]); err != nil {
410
+ return exitUsage
411
+ }
412
+ wfs, err := c.ListWorkflows(context.Background())
413
+ if err != nil {
414
+ fmt.Fprintln(os.Stderr, err)
415
+ return exitFail
141
416
  }
142
- daemonize(logPath, childArgs...)
143
- return
417
+ for _, wf := range wfs {
418
+ fmt.Println(wf.Name)
419
+ }
420
+ return exitOK
421
+ case "get":
422
+ fs := flag.NewFlagSet("workflow get", flag.ContinueOnError)
423
+ name := fs.String("name", "", "workflow name")
424
+ if err := fs.Parse(args[1:]); err != nil {
425
+ return exitUsage
426
+ }
427
+ if *name == "" {
428
+ fmt.Fprintln(os.Stderr, "workflow get: --name is required")
429
+ return exitUsage
430
+ }
431
+ wf, err := c.GetWorkflow(context.Background(), *name)
432
+ if err != nil {
433
+ fmt.Fprintln(os.Stderr, err)
434
+ return exitFail
435
+ }
436
+ enc := json.NewEncoder(os.Stdout)
437
+ enc.SetIndent("", " ")
438
+ _ = enc.Encode(wf)
439
+ return exitOK
144
440
  }
441
+ usage(os.Stderr)
442
+ return exitUsage
443
+ }
444
+
445
+ // --- repo ---
145
446
 
146
- // Tee logs to server.log when run interactively; daemonized child
147
- // already has stderr attached to the log file.
148
- if os.Getenv("RELAY_FLOW_DAEMONIZED") == "" {
149
- if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
150
- log.SetOutput(io.MultiWriter(os.Stderr, f))
151
- defer f.Close()
447
+ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
448
+ if len(args) == 0 {
449
+ usage(os.Stderr)
450
+ return exitUsage
451
+ }
452
+ switch args[0] {
453
+ case "register":
454
+ fs := flag.NewFlagSet("repo register", flag.ContinueOnError)
455
+ name := fs.String("name", "", "repo name (non-interactive)")
456
+ path := fs.String("path", "", "repo path (non-interactive)")
457
+ sets := kvFlags{}
458
+ fs.Var(&sets, "set", "required task repo key value, key=value (repeatable)")
459
+ if err := fs.Parse(args[1:]); err != nil {
460
+ return exitUsage
461
+ }
462
+ return cmdRepoRegister(c, *name, *path, sets, stdin)
463
+ case "remove":
464
+ fs := flag.NewFlagSet("repo remove", flag.ContinueOnError)
465
+ name := fs.String("name", "", "repo name")
466
+ if err := fs.Parse(args[1:]); err != nil {
467
+ return exitUsage
468
+ }
469
+ if *name == "" {
470
+ fmt.Fprintln(os.Stderr, "repo remove: --name is required")
471
+ return exitUsage
472
+ }
473
+ if err := c.RemoveRepo(context.Background(), *name); err != nil {
474
+ fmt.Fprintln(os.Stderr, err)
475
+ return exitFail
476
+ }
477
+ return exitOK
478
+ case "list":
479
+ fs := flag.NewFlagSet("repo list", flag.ContinueOnError)
480
+ if err := fs.Parse(args[1:]); err != nil {
481
+ return exitUsage
482
+ }
483
+ infos, err := c.ListRepos(context.Background())
484
+ if err != nil {
485
+ fmt.Fprintln(os.Stderr, err)
486
+ return exitFail
487
+ }
488
+ for _, info := range infos {
489
+ fmt.Println(info.Name)
490
+ }
491
+ return exitOK
492
+ case "get":
493
+ fs := flag.NewFlagSet("repo get", flag.ContinueOnError)
494
+ name := fs.String("name", "", "repo name")
495
+ if err := fs.Parse(args[1:]); err != nil {
496
+ return exitUsage
497
+ }
498
+ if *name == "" {
499
+ fmt.Fprintln(os.Stderr, "repo get: --name is required")
500
+ return exitUsage
152
501
  }
502
+ info, err := c.GetRepo(context.Background(), *name)
503
+ if err != nil {
504
+ fmt.Fprintln(os.Stderr, err)
505
+ return exitFail
506
+ }
507
+ enc := json.NewEncoder(os.Stdout)
508
+ enc.SetIndent("", " ")
509
+ _ = enc.Encode(info)
510
+ return exitOK
153
511
  }
512
+ usage(os.Stderr)
513
+ return exitUsage
514
+ }
154
515
 
155
- // Single instance enforcement via flock: the kernel holds the lock for
156
- // this process's life and releases it on ANY exit (clean, crash,
157
- // kill -9), so there is never stale state to clean up. No pid file.
158
- releaseLock, err := discovery.AcquireServerLock()
159
- if err != nil {
160
- log.Fatalf("%v", err)
516
+ // cmdRepoRegister registers one repo. Fully-flagged invocation
517
+ // (--name/--path plus --set for every required task repo key) never
518
+ // prompts and produces the same repo entry as the interactive run.
519
+ // With no flags, a TTY drives the interactive flow: discover runner
520
+ // repos, huh searchable select, then prompts for name, path, and each
521
+ // required key with the selected candidate's name/path as defaults.
522
+ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
523
+ ctx := context.Background()
524
+ flagged := flagName != "" || flagPath != "" || len(sets) > 0
525
+
526
+ if flagged {
527
+ // Presence validation BEFORE any server contact.
528
+ if flagName == "" || flagPath == "" {
529
+ fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
530
+ return exitUsage
531
+ }
532
+ taskCfg := config.RawValues{}
533
+ for k, v := range sets {
534
+ if v == "" {
535
+ fmt.Fprintf(os.Stderr, "repo register: --set %s requires a non-empty value\n", k)
536
+ return exitUsage
537
+ }
538
+ taskCfg[k] = v
539
+ }
540
+ fields, err := c.RepoTaskFields(ctx)
541
+ if err != nil {
542
+ fmt.Fprintln(os.Stderr, err)
543
+ return exitFail
544
+ }
545
+ required := map[string]bool{}
546
+ for _, f := range fields {
547
+ required[f] = true
548
+ }
549
+ for k := range taskCfg {
550
+ if !required[k] {
551
+ fmt.Fprintf(os.Stderr, "repo register: unknown task key %q (required keys: %s)\n",
552
+ k, strings.Join(fields, ", "))
553
+ return exitUsage
554
+ }
555
+ }
556
+ var missing []string
557
+ for _, f := range fields {
558
+ if _, ok := taskCfg[f]; !ok {
559
+ missing = append(missing, f)
560
+ }
561
+ }
562
+ if len(missing) > 0 {
563
+ fmt.Fprintf(os.Stderr, "repo register: missing required task keys: %s (pass --set %s=<value>)\n",
564
+ strings.Join(missing, ", "), missing[0])
565
+ return exitUsage
566
+ }
567
+ info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
568
+ if err != nil {
569
+ fmt.Fprintln(os.Stderr, err)
570
+ return exitFail
571
+ }
572
+ fmt.Println(info.Name)
573
+ return exitOK
161
574
  }
162
- defer releaseLock()
163
575
 
164
- sockPath, err := discovery.SocketPath()
576
+ fields, err := c.RepoTaskFields(ctx)
165
577
  if err != nil {
166
- log.Fatalf("%v", err)
578
+ fmt.Fprintln(os.Stderr, err)
579
+ return exitFail
167
580
  }
168
- os.Remove(sockPath) // stale socket from a crashed server
169
- ln, err := net.Listen("unix", sockPath)
170
- if err != nil {
171
- log.Fatalf("listen %s: %v", sockPath, err)
172
- }
173
- defer os.Remove(sockPath)
174
-
175
- srv := server.New(*dryRun, server.ProdDeps(*dryRun))
176
- log.Printf("relay-flow serve: socket=%s dry-run=%v", sockPath, *dryRun)
177
-
178
- sig := make(chan os.Signal, 1)
179
- signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
180
- go func() {
181
- <-sig
182
- log.Printf("shutting down")
183
- srv.Shutdown()
184
- }()
185
- if err := srv.Serve(ln); err != nil {
186
- log.Fatalf("serve: %v", err)
187
- }
188
- }
189
-
190
- // cmdReport is invoked by the opencode plugin once per session.idle.
191
- // Thin socket client: the server resolves the outcome edge and calls the
192
- // tasks adapter — no config load, no tracker calls, no fallback. The
193
- // agent terminal is deliberately NOT closed here: it stays alive for
194
- // bounce nudges; only closeOn nodes close terminals.
195
- func cmdReport(args []string) {
196
- fs := flag.NewFlagSet("report", flag.ExitOnError)
197
- workflow := fs.String("workflow", "", "workflow name")
198
- ticket := fs.String("ticket", "", "ticket key")
199
- node := fs.String("node", "", "node name")
200
- outcome := fs.String("outcome", "", "success or failure")
201
- summary := fs.String("summary", "", "agent's summary of what it did")
202
- fs.Parse(args)
203
- if *workflow == "" || *ticket == "" || *node == "" || *outcome == "" || *summary == "" {
204
- log.Fatalf("usage: relay-flow report --workflow <name> --ticket <key> --node <node> --outcome <success|failure> --summary <text>")
205
- }
206
- client, err := server.NewClient()
207
- if err != nil {
208
- log.Fatalf("%v", err)
581
+
582
+ if !isTTY(stdin) {
583
+ fmt.Fprintln(os.Stderr, "repo register: interactive registration requires a TTY (or pass --name/--path/--set)")
584
+ return exitFail
209
585
  }
210
- result, err := client.Report(*workflow, *ticket, *node, *outcome, *summary)
586
+ candidates, err := c.DiscoverRepos(ctx)
211
587
  if err != nil {
212
- log.Fatalf("report %s: %v", *ticket, err)
588
+ fmt.Fprintln(os.Stderr, err)
589
+ return exitFail
590
+ }
591
+ if len(candidates) == 0 {
592
+ fmt.Fprintln(os.Stderr, "repo register: runner discovered no repos")
593
+ return exitFail
213
594
  }
214
- // JSON on stdout (log goes to stderr) so the plugin can parse it.
215
- out, _ := json.Marshal(map[string]string{"action": result.Action, "detail": result.Detail})
216
- fmt.Println(string(out))
217
- log.Printf("report %s: workflow=%s node=%s action=%s detail=%q", *ticket, *workflow, *node, result.Action, result.Detail)
218
- }
219
595
 
220
- // cmdSubmit reads a workflow YAML and sends it to the running server. cwd
221
- // must be inside the repo the workflow governs (repo resolved client-side).
222
- // The workflow's identity is the `name` field inside the YAML.
223
- func cmdSubmit(args []string) {
224
- fs := flag.NewFlagSet("submit", flag.ExitOnError)
225
- file := fs.String("f", "", "path to workflow YAML (default .workflow/workflow.yaml)")
226
- fs.Parse(args)
227
- if fs.NArg() != 0 {
228
- log.Fatalf("usage: relay-flow submit [-f <yaml>] (workflow name comes from the YAML's `name` field)")
596
+ // Searchable select over discovered candidates.
597
+ selected := 0
598
+ options := make([]huh.Option[int], len(candidates))
599
+ for i, cand := range candidates {
600
+ options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
229
601
  }
230
- path := *file
231
- if path == "" {
232
- path = filepath.Join(".workflow", "workflow.yaml")
602
+ pick := huh.NewForm(huh.NewGroup(huh.NewSelect[int]().
603
+ Title("Repository").
604
+ Options(options...).
605
+ Filtering(true).
606
+ Value(&selected)))
607
+ if err := pick.Run(); err != nil {
608
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
609
+ return exitFail
233
610
  }
234
- yamlBytes, err := os.ReadFile(path)
235
- if err != nil {
236
- log.Fatalf("read %s: %v", path, err)
611
+
612
+ // Name/path inputs default to the SELECTED candidate, then one input
613
+ // per required task repo key. Values pass through as entered; the
614
+ // service validates required keys.
615
+ cand := candidates[selected]
616
+ name := cand.Name
617
+ path := cand.Path
618
+ inputs := []huh.Field{
619
+ huh.NewInput().Title("Name").Value(&name),
620
+ huh.NewInput().Title("Path").Value(&path),
237
621
  }
238
- cwd, err := os.Getwd()
239
- if err != nil {
240
- log.Fatalf("%v", err)
622
+ values := make([]string, len(fields))
623
+ for i, f := range fields {
624
+ inputs = append(inputs, huh.NewInput().Title(f).Value(&values[i]))
625
+ }
626
+ if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
627
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
628
+ return exitFail
241
629
  }
242
- client, err := server.NewClient()
630
+ taskCfg := config.RawValues{}
631
+ for i, f := range fields {
632
+ taskCfg[f] = values[i]
633
+ }
634
+ info, err := c.RegisterRepo(ctx, repo.RegisterInput{
635
+ Name: name,
636
+ Path: path,
637
+ TaskConfig: taskCfg,
638
+ })
243
639
  if err != nil {
244
- log.Fatalf("%v", err)
640
+ fmt.Fprintln(os.Stderr, err)
641
+ return exitFail
642
+ }
643
+ fmt.Println(info.Name)
644
+ return exitOK
645
+ }
646
+
647
+ // kvFlags collects repeated key=value flags (registration task config).
648
+ // Parse rejects malformed pairs, empty keys/values, and duplicates.
649
+ type kvFlags map[string]string
650
+
651
+ func (k kvFlags) String() string { return "" }
652
+ func (k kvFlags) Set(s string) error {
653
+ kv := strings.SplitN(s, "=", 2)
654
+ if len(kv) != 2 || kv[0] == "" || kv[1] == "" {
655
+ return fmt.Errorf("expected key=value with non-empty key and value, got %q", s)
656
+ }
657
+ if _, dup := k[kv[0]]; dup {
658
+ return fmt.Errorf("duplicate --set for key %q", kv[0])
659
+ }
660
+ k[kv[0]] = kv[1]
661
+ return nil
662
+ }
663
+
664
+ // --- run ---
665
+
666
+ func cmdRun(c *server.Client, args []string) int {
667
+ if len(args) == 0 {
668
+ usage(os.Stderr)
669
+ return exitUsage
670
+ }
671
+ switch args[0] {
672
+ case "list":
673
+ fs := flag.NewFlagSet("run list", flag.ContinueOnError)
674
+ repoName := fs.String("repo", "", "filter by repo")
675
+ workflowName := fs.String("workflow", "", "filter by workflow")
676
+ ticket := fs.String("ticket", "", "filter by ticket")
677
+ if err := fs.Parse(args[1:]); err != nil {
678
+ return exitUsage
679
+ }
680
+ runs, err := c.ListRuns(context.Background(), runsvc.Filter{
681
+ Repo: *repoName,
682
+ Workflow: *workflowName,
683
+ Ticket: *ticket,
684
+ })
685
+ if err != nil {
686
+ fmt.Fprintln(os.Stderr, err)
687
+ return exitFail
688
+ }
689
+ for _, r := range runs {
690
+ fmt.Println(formatRunListRow(r))
691
+ }
692
+ return exitOK
693
+ case "get":
694
+ fs := flag.NewFlagSet("run get", flag.ContinueOnError)
695
+ ticket := fs.String("ticket", "", "ticket key")
696
+ if err := fs.Parse(args[1:]); err != nil {
697
+ return exitUsage
698
+ }
699
+ if *ticket == "" {
700
+ fmt.Fprintln(os.Stderr, "run get: --ticket is required")
701
+ return exitUsage
702
+ }
703
+ rn, err := c.GetRunByTicket(context.Background(), *ticket)
704
+ if err != nil {
705
+ fmt.Fprintln(os.Stderr, err)
706
+ return exitFail
707
+ }
708
+ enc := json.NewEncoder(os.Stdout)
709
+ enc.SetIndent("", " ")
710
+ _ = enc.Encode(rn)
711
+ return exitOK
712
+ case "cancel":
713
+ fs := flag.NewFlagSet("run cancel", flag.ContinueOnError)
714
+ ticket := fs.String("ticket", "", "ticket key")
715
+ reason := fs.String("reason", "", "cancel reason")
716
+ if err := fs.Parse(args[1:]); err != nil {
717
+ return exitUsage
718
+ }
719
+ if *ticket == "" {
720
+ fmt.Fprintln(os.Stderr, "run cancel: --ticket is required")
721
+ return exitUsage
722
+ }
723
+ if err := c.CancelRun(context.Background(), *ticket, *reason); err != nil {
724
+ fmt.Fprintln(os.Stderr, err)
725
+ return exitFail
726
+ }
727
+ return exitOK
245
728
  }
246
- if err := client.Submit(cwd, yamlBytes); err != nil {
247
- log.Fatalf("submit: %v", err)
729
+ usage(os.Stderr)
730
+ return exitUsage
731
+ }
732
+
733
+ func formatRunListRow(r runsvc.Run) string {
734
+ row := fmt.Sprintf("%s\t%s\t%s\t%s", r.ID, r.Ticket.Key, r.Workflow, r.State)
735
+ if r.Retry != nil {
736
+ row += fmt.Sprintf("\tretrying attempt=%d next=%s error=%q",
737
+ r.Retry.Attempt, r.Retry.NextRetryAt.Format(time.RFC3339), r.Retry.LastError)
248
738
  }
249
- fmt.Println("submitted")
739
+ return row
250
740
  }