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
@@ -1,250 +1,952 @@
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"
15
+ "log/slog"
14
16
  "os"
15
17
  "os/exec"
16
18
  "os/signal"
17
19
  "path/filepath"
18
-
20
+ "strings"
19
21
  "syscall"
22
+ "time"
20
23
 
21
- "github.com/rajpopat27/relay-flow/internal/acli"
24
+ "github.com/charmbracelet/huh"
25
+ "github.com/mattn/go-isatty"
22
26
  "github.com/rajpopat27/relay-flow/internal/config"
23
- "github.com/rajpopat27/relay-flow/internal/discovery"
27
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
28
+ "github.com/rajpopat27/relay-flow/internal/harness"
29
+ "github.com/rajpopat27/relay-flow/internal/logging"
30
+ "github.com/rajpopat27/relay-flow/internal/paths"
31
+ "github.com/rajpopat27/relay-flow/internal/repo"
32
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
33
+ "github.com/rajpopat27/relay-flow/internal/runner"
24
34
  "github.com/rajpopat27/relay-flow/internal/server"
35
+ "github.com/rajpopat27/relay-flow/internal/task"
36
+
37
+ // Adapter registrations (factories registered via init for plugin
38
+ // name validation at init-time).
39
+ _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
40
+ _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
41
+ _ "github.com/rajpopat27/relay-flow/internal/task/jira"
42
+ )
43
+
44
+ // Exit codes per specs/workflow-repo-management "CLI exit codes are
45
+ // stable": 0 success, 2 command/flag usage, 1 server/validation/operation.
46
+ const (
47
+ exitOK = 0
48
+ exitFail = 1
49
+ exitUsage = 2
25
50
  )
26
51
 
27
- func main() {
28
- if len(os.Args) < 2 {
29
- usage()
30
- os.Exit(1)
52
+ func main() { os.Exit(run(os.Args[1:], os.Stdin)) }
53
+
54
+ // home returns the relay-flow root, honoring RELAY_FLOW_HOME (tests and
55
+ // non-standard installs) and falling back to ~/.relay-flow.
56
+ func home() (paths.Paths, error) {
57
+ if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
58
+ root := env
59
+ return paths.Paths{
60
+ Root: root,
61
+ Config: filepath.Join(root, "config.yaml"),
62
+ Credentials: filepath.Join(root, "credentials.yaml"),
63
+ Workflows: filepath.Join(root, "workflows"),
64
+ Database: filepath.Join(root, "state.db"),
65
+ Socket: filepath.Join(root, "server.sock"),
66
+ Lock: filepath.Join(root, "server.lock"),
67
+ ServerLog: filepath.Join(root, "server.log"),
68
+ PluginLog: filepath.Join(root, "plugin.log"),
69
+ }, nil
31
70
  }
32
- switch os.Args[1] {
71
+ return paths.ForUserHome()
72
+ }
73
+
74
+ // run parses args and dispatches one command. It is the test seam: stdin
75
+ // is injectable so the report command can be driven in-process.
76
+ func run(args []string, stdin io.Reader) int {
77
+ if len(args) == 0 {
78
+ usage(os.Stderr)
79
+ return exitUsage
80
+ }
81
+ p, err := home()
82
+ if err != nil {
83
+ fmt.Fprintln(os.Stderr, err)
84
+ return exitFail
85
+ }
86
+ client := server.NewClient(p.Socket)
87
+
88
+ switch args[0] {
33
89
  case "init":
34
- cmdInit(os.Args[2:])
35
- case "stop":
36
- cmdStop(os.Args[2:])
90
+ return cmdInit(p, args[1:], stdin)
37
91
  case "serve":
38
- cmdServe(os.Args[2:])
39
- case "submit":
40
- cmdSubmit(os.Args[2:])
92
+ return cmdServe(p, args[1:])
93
+ case "stop":
94
+ fs := flag.NewFlagSet("stop", flag.ContinueOnError)
95
+ if err := fs.Parse(args[1:]); err != nil {
96
+ return exitUsage
97
+ }
98
+ if err := client.Stop(context.Background()); err != nil {
99
+ fmt.Fprintln(os.Stderr, err)
100
+ return exitFail
101
+ }
102
+ return exitOK
41
103
  case "report":
42
- cmdReport(os.Args[2:])
104
+ fs := flag.NewFlagSet("report", flag.ContinueOnError)
105
+ if err := fs.Parse(args[1:]); err != nil {
106
+ return exitUsage
107
+ }
108
+ return cmdReport(client, stdin)
109
+ case "runtime-register":
110
+ fs := flag.NewFlagSet("runtime-register", flag.ContinueOnError)
111
+ if err := fs.Parse(args[1:]); err != nil {
112
+ return exitUsage
113
+ }
114
+ return cmdRuntimeRegister(client, stdin)
115
+ case "task":
116
+ return cmdTask(p, args[1:], stdin)
117
+ case "workflow":
118
+ return cmdWorkflow(client, args[1:])
119
+ case "repo":
120
+ return cmdRepo(client, args[1:], stdin)
121
+ case "run":
122
+ return cmdRun(client, args[1:])
43
123
  default:
44
- usage()
45
- os.Exit(1)
124
+ usage(os.Stderr)
125
+ return exitUsage
46
126
  }
47
127
  }
48
128
 
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/")
129
+ func usage(w io.Writer) {
130
+ fmt.Fprintln(w, `relay-flow — durable ticket runner
131
+
132
+ Usage:
133
+ relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
134
+ relay-flow task auth [task-plugin options]
135
+ relay-flow serve [--recover] [--debug] [--background]
136
+ relay-flow stop
137
+ relay-flow report
138
+
139
+ relay-flow workflow submit --file <path>
140
+ relay-flow workflow remove --name <name>
141
+ relay-flow workflow list
142
+ relay-flow workflow get --name <name>
143
+
144
+ relay-flow repo register [--name <name> --path <path> --set key=value ...]
145
+ relay-flow repo remove --name <name>
146
+ relay-flow repo list
147
+ relay-flow repo get --name <name>
148
+
149
+ relay-flow run list
150
+ relay-flow run get --ticket <key>
151
+ relay-flow run cancel --ticket <key>`)
56
152
  }
57
153
 
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)
154
+ // --- init / serve delegate to the section-5 composition root ---
155
+
156
+ // cmdInit is the init composition root (docs lines 950/1028): select the
157
+ // three plugin names, atomically write the machine config, and initialize
158
+ // the SQLite database. Refuses to overwrite existing config or history unless
159
+ // --force safely updates plugin selections while preserving durable state.
160
+ // Plugin selection precedence: --task-plugin/--runner-plugin/--harness-plugin
161
+ // flags (all three required for a fully non-interactive run) → huh form on
162
+ // a TTY → three stdin lines (task, runner, harness), the documented test
163
+ // seam and script path.
164
+ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
165
+ fs := flag.NewFlagSet("init", flag.ContinueOnError)
166
+ taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
167
+ runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
168
+ harnessName := fs.String("harness-plugin", "", "harness plugin name (non-interactive)")
169
+ force := fs.Bool("force", false, "update plugin selections while preserving existing state")
170
+ if err := fs.Parse(args); err != nil {
171
+ return exitUsage
172
+ }
173
+ flagged := *taskName != "" || *runnerName != "" || *harnessName != ""
174
+ if flagged && (*taskName == "" || *runnerName == "" || *harnessName == "") {
175
+ fmt.Fprintln(os.Stderr, "init: --task-plugin, --runner-plugin, and --harness-plugin must be given together")
176
+ return exitUsage
177
+ }
178
+ configExists, err := pathExists(p.Config)
63
179
  if err != nil {
64
- log.Fatalf("open log file: %v", err)
180
+ fmt.Fprintln(os.Stderr, err)
181
+ return exitFail
65
182
  }
66
- self, err := os.Executable()
183
+ databaseExists, err := pathExists(p.Database)
67
184
  if err != nil {
68
- log.Fatalf("resolve executable: %v", err)
185
+ fmt.Fprintln(os.Stderr, err)
186
+ return exitFail
69
187
  }
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)
188
+ if !*force && configExists {
189
+ fmt.Fprintln(os.Stderr, "init: already initialized (config exists): "+p.Config)
190
+ return exitFail
191
+ }
192
+ if !*force && databaseExists {
193
+ fmt.Fprintln(os.Stderr, "init: already initialized (database exists): "+p.Database)
194
+ return exitFail
195
+ }
196
+ if *force && configExists && !databaseExists {
197
+ fmt.Fprintln(os.Stderr, "init: database is missing; refusing to recreate existing durable state")
198
+ return exitFail
199
+ }
200
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
201
+ fmt.Fprintln(os.Stderr, err)
202
+ return exitFail
203
+ }
204
+ _ = os.Chmod(p.Root, 0o700)
205
+ var unlock func()
206
+ if *force {
207
+ unlock, err = lockForForcedInit(p.Lock)
208
+ if err != nil {
209
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
210
+ return exitFail
211
+ }
212
+ defer unlock()
213
+ if databaseExists {
214
+ active, err := goworkflows.HasNonterminalRuns(p.Database)
215
+ if err != nil {
216
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
217
+ return exitFail
218
+ }
219
+ if active {
220
+ fmt.Fprintln(os.Stderr, "init: cannot use --force while a run is nonterminal")
221
+ return exitFail
222
+ }
223
+ }
76
224
  }
77
- fmt.Printf("started (pid %d), logging to %s\n", cmd.Process.Pid, logPath)
78
- }
79
225
 
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>\"")
226
+ // Selection precedence: flags → TTY form → stdin lines. The stdin path
227
+ // is the documented test seam and script path.
228
+ var names []string
229
+ switch {
230
+ case flagged:
231
+ names = []string{*taskName, *runnerName, *harnessName}
232
+ case isTTY(stdin):
233
+ var err error
234
+ names, err = pickPluginsInteractive()
235
+ if err != nil {
236
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
237
+ return exitFail
238
+ }
239
+ default:
240
+ names = readInitLines(stdin)
241
+ if names == nil {
242
+ fmt.Fprintln(os.Stderr, "init: expected task, runner, and harness plugin selections on stdin")
243
+ return exitFail
244
+ }
88
245
  }
89
- if err := acli.New().ValidateAssignee(*assignee); err != nil {
90
- log.Fatalf("%v", err)
246
+ // Validate against the registered factories; unknown names list the
247
+ // registered set per design (no silent acceptance).
248
+ if err := task.ValidateName(names[0]); err != nil {
249
+ fmt.Fprintln(os.Stderr, "init: task plugin: "+err.Error())
250
+ return exitFail
91
251
  }
92
- if err := (&config.MachineConfig{Assignee: *assignee}).Save(); err != nil {
93
- log.Fatalf("%v", err)
252
+ if err := runner.ValidateName(names[1]); err != nil {
253
+ fmt.Fprintln(os.Stderr, "init: runner plugin: "+err.Error())
254
+ return exitFail
255
+ }
256
+ if err := harness.ValidateName(names[2]); err != nil {
257
+ fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
258
+ return exitFail
94
259
  }
95
- p, _ := config.MachineConfigPath()
96
- fmt.Printf("machine config written to %s (assignee=%q)\n", p, *assignee)
97
- }
98
260
 
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")
261
+ cfg := &config.Machine{KeepTerminalsAlive: true, KeepSessionsAlive: true}
262
+ if *force && configExists {
263
+ cfg, err = config.LoadMachine(p.Config)
264
+ if err != nil {
265
+ fmt.Fprintln(os.Stderr, err)
266
+ return exitFail
267
+ }
104
268
  }
105
- client, err := server.NewClient()
106
- if err != nil {
107
- log.Fatalf("%v", err)
269
+ cfg.TaskPlugin = names[0]
270
+ cfg.RunnerPlugin = names[1]
271
+ cfg.HarnessPlugin = names[2]
272
+ if err := config.SaveMachine(p.Config, cfg); err != nil {
273
+ fmt.Fprintln(os.Stderr, err)
274
+ return exitFail
108
275
  }
109
- if err := client.Shutdown(); err != nil {
110
- log.Fatalf("%v", err)
276
+ if !databaseExists {
277
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
278
+ fmt.Fprintln(os.Stderr, err)
279
+ return exitFail
280
+ }
111
281
  }
112
- fmt.Println("server stopped")
282
+ fmt.Printf("Task system: %s\nRunner: %s\nHarness: %s\nRelay-flow initialized\n", names[0], names[1], names[2])
283
+ return exitOK
113
284
  }
114
285
 
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)
286
+ func pathExists(path string) (bool, error) {
287
+ _, err := os.Stat(path)
288
+ if err == nil {
289
+ return true, nil
290
+ }
291
+ if os.IsNotExist(err) {
292
+ return false, nil
293
+ }
294
+ return false, fmt.Errorf("stat %s: %w", path, err)
295
+ }
122
296
 
123
- home, err := os.UserHomeDir()
297
+ func lockForForcedInit(path string) (func(), error) {
298
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
124
299
  if err != nil {
125
- log.Fatalf("%v", err)
300
+ return nil, fmt.Errorf("open server lock: %w", err)
126
301
  }
127
- logPath := filepath.Join(home, ".relay-flow", "server.log")
302
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
303
+ f.Close()
304
+ return nil, fmt.Errorf("cannot use --force while the server is running")
305
+ }
306
+ return func() {
307
+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
308
+ _ = f.Close()
309
+ }, nil
310
+ }
128
311
 
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()
312
+ // isTTY reports whether stdin is an interactive terminal.
313
+ func isTTY(stdin io.Reader) bool {
314
+ f, ok := stdin.(*os.File)
315
+ return ok && isatty.IsTerminal(f.Fd())
316
+ }
317
+
318
+ // pickPluginsInteractive runs one searchable select per plugin type.
319
+ func pickPluginsInteractive() ([]string, error) {
320
+ names := make([]string, 3)
321
+ groups := make([]*huh.Group, 0, 3)
322
+ for _, selection := range []struct {
323
+ title string
324
+ options []string
325
+ value *string
326
+ }{
327
+ {"Select task system", task.Names(), &names[0]},
328
+ {"Select runner", runner.Names(), &names[1]},
329
+ {"Select harness", harness.Names(), &names[2]},
330
+ } {
331
+ field, err := pluginSelectField(selection.title, selection.options, selection.value)
134
332
  if err != nil {
135
- log.Fatalf("%v", err)
333
+ return nil, err
136
334
  }
137
- release()
138
- childArgs := []string{"serve", "--foreground"}
139
- if *dryRun {
140
- childArgs = append(childArgs, "--dry-run")
335
+ if field != nil {
336
+ groups = append(groups, huh.NewGroup(field))
141
337
  }
142
- daemonize(logPath, childArgs...)
143
- return
144
338
  }
339
+ if len(groups) == 0 {
340
+ return names, nil
341
+ }
342
+ form := huh.NewForm(groups...)
343
+ if err := form.Run(); err != nil {
344
+ return nil, err
345
+ }
346
+ return names, nil
347
+ }
348
+
349
+ func pluginSelectField(title string, options []string, value *string) (huh.Field, error) {
350
+ if len(options) == 0 {
351
+ return nil, fmt.Errorf("%s: no plugins registered", title)
352
+ }
353
+ return huh.NewSelect[string]().
354
+ Title(title).
355
+ Description("Press / to filter available plugins.").
356
+ Options(huh.NewOptions(options...)...).
357
+ Value(value), nil
358
+ }
145
359
 
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()
360
+ // readInitLines reads the three plugin selections.
361
+ func readInitLines(stdin io.Reader) []string {
362
+ values := make([]string, 0, 3)
363
+ scanner := bufio.NewScanner(stdin)
364
+ for len(values) < 3 && scanner.Scan() {
365
+ line := strings.TrimSpace(scanner.Text())
366
+ if line == "" {
367
+ continue
152
368
  }
369
+ values = append(values, line)
153
370
  }
371
+ if len(values) < 3 {
372
+ return nil
373
+ }
374
+ return values
375
+ }
154
376
 
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)
377
+ // cmdTask dispatches task-system commands to the selected task plugin.
378
+ func cmdTask(p paths.Paths, args []string, stdin io.Reader) int {
379
+ if len(args) == 0 || args[0] != "auth" {
380
+ usage(os.Stderr)
381
+ return exitUsage
161
382
  }
162
- defer releaseLock()
383
+ return cmdTaskAuth(p, args[1:], stdin)
384
+ }
163
385
 
164
- sockPath, err := discovery.SocketPath()
386
+ func cmdTaskAuth(p paths.Paths, args []string, stdin io.Reader) int {
387
+ cfg, err := config.LoadMachine(p.Config)
165
388
  if err != nil {
166
- log.Fatalf("%v", err)
389
+ fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
390
+ return exitFail
391
+ }
392
+ if err := task.Auth(context.Background(), cfg.TaskPlugin, args, stdin); err != nil {
393
+ fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
394
+ return exitFail
167
395
  }
168
- os.Remove(sockPath) // stale socket from a crashed server
169
- ln, err := net.Listen("unix", sockPath)
396
+ return exitOK
397
+ }
398
+
399
+ func cmdServe(p paths.Paths, args []string) int {
400
+ fs := flag.NewFlagSet("serve", flag.ContinueOnError)
401
+ recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
402
+ debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
403
+ background := fs.Bool("background", false, "start the server detached and return after readiness")
404
+ if err := fs.Parse(args); err != nil {
405
+ return exitUsage
406
+ }
407
+ if *background {
408
+ if err := startBackgroundServe(p, *recover, *debug); err != nil {
409
+ fmt.Fprintln(os.Stderr, err)
410
+ return exitFail
411
+ }
412
+ fmt.Println("Relay-flow server started")
413
+ return exitOK
414
+ }
415
+ logCloser, err := logging.Setup(p.ServerLog, logging.Options{
416
+ Debug: *debug,
417
+ Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
418
+ })
170
419
  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()
420
+ fmt.Fprintln(os.Stderr, err)
421
+ return exitFail
422
+ }
423
+ defer logCloser.Close()
424
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
425
+ defer stop()
426
+ if err := serveRoot(ctx, p, *recover); err != nil {
427
+ slog.Error("server startup failed", "error", err)
428
+ fmt.Fprintln(os.Stderr, err)
429
+ return exitFail
430
+ }
431
+ return exitOK
432
+ }
433
+
434
+ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
435
+ client := server.NewClient(p.Socket)
436
+ if serverResponding(client, 200*time.Millisecond) {
437
+ return fmt.Errorf("serve --background: server is already running")
438
+ }
439
+ executable, err := os.Executable()
207
440
  if err != nil {
208
- log.Fatalf("%v", err)
441
+ return fmt.Errorf("serve --background: resolve executable: %w", err)
209
442
  }
210
- result, err := client.Report(*workflow, *ticket, *node, *outcome, *summary)
443
+ childArgs := []string{"serve"}
444
+ if recover {
445
+ childArgs = append(childArgs, "--recover")
446
+ }
447
+ if debug {
448
+ childArgs = append(childArgs, "--debug")
449
+ }
450
+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
211
451
  if err != nil {
212
- log.Fatalf("report %s: %v", *ticket, err)
452
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
453
+ }
454
+ defer devNull.Close()
455
+ cmd := exec.Command(executable, childArgs...)
456
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
457
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
458
+ if err := cmd.Start(); err != nil {
459
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
460
+ }
461
+ wait := make(chan error, 1)
462
+ go func() { wait <- cmd.Wait() }()
463
+ deadline := time.NewTimer(10 * time.Second)
464
+ defer deadline.Stop()
465
+ ticker := time.NewTicker(50 * time.Millisecond)
466
+ defer ticker.Stop()
467
+ for {
468
+ select {
469
+ case err := <-wait:
470
+ return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", err, p.ServerLog)
471
+ case <-deadline.C:
472
+ _ = cmd.Process.Kill()
473
+ <-wait
474
+ return fmt.Errorf("serve --background: startup timed out; see %s", p.ServerLog)
475
+ case <-ticker.C:
476
+ if serverResponding(client, 200*time.Millisecond) {
477
+ return nil
478
+ }
479
+ }
213
480
  }
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
481
  }
219
482
 
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)")
483
+ func serverResponding(client *server.Client, timeout time.Duration) bool {
484
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
485
+ defer cancel()
486
+ _, err := client.ListRepos(ctx)
487
+ return err == nil
488
+ }
489
+
490
+ // --- report ---
491
+
492
+ // cmdReport reads one JSON object from stdin and posts it to /reports.
493
+ // Exit 0 on any ack (including duplicate/stale), 1 on server/validation
494
+ // failure or malformed JSON.
495
+ func cmdReport(c *server.Client, stdin io.Reader) int {
496
+ body, err := io.ReadAll(stdin)
497
+ if err != nil {
498
+ fmt.Fprintln(os.Stderr, err)
499
+ return exitFail
500
+ }
501
+ var req runsvc.ReportRequest
502
+ if err := json.Unmarshal(body, &req); err != nil {
503
+ fmt.Fprintln(os.Stderr, "report: malformed JSON: "+err.Error())
504
+ return exitFail
229
505
  }
230
- path := *file
231
- if path == "" {
232
- path = filepath.Join(".workflow", "workflow.yaml")
506
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
507
+ defer cancel()
508
+ if _, err := c.SubmitReport(ctx, req); err != nil {
509
+ fmt.Fprintln(os.Stderr, err)
510
+ return exitFail
233
511
  }
234
- yamlBytes, err := os.ReadFile(path)
512
+ return exitOK
513
+ }
514
+
515
+ // cmdRuntimeRegister reads one plugin-owned JSON session registration from
516
+ // stdin and forwards it to the server over the existing Unix socket.
517
+ func cmdRuntimeRegister(c *server.Client, stdin io.Reader) int {
518
+ body, err := io.ReadAll(stdin)
235
519
  if err != nil {
236
- log.Fatalf("read %s: %v", path, err)
520
+ fmt.Fprintln(os.Stderr, err)
521
+ return exitFail
522
+ }
523
+ var registration runsvc.NodeRuntimeRegistration
524
+ if err := json.Unmarshal(body, &registration); err != nil {
525
+ fmt.Fprintln(os.Stderr, "runtime-register: malformed JSON: "+err.Error())
526
+ return exitFail
527
+ }
528
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
529
+ defer cancel()
530
+ if _, err := c.RegisterNodeSession(ctx, registration); err != nil {
531
+ fmt.Fprintln(os.Stderr, err)
532
+ return exitFail
237
533
  }
238
- cwd, err := os.Getwd()
534
+ return exitOK
535
+ }
536
+
537
+ // --- workflow ---
538
+
539
+ func cmdWorkflow(c *server.Client, args []string) int {
540
+ if len(args) == 0 {
541
+ usage(os.Stderr)
542
+ return exitUsage
543
+ }
544
+ switch args[0] {
545
+ case "submit":
546
+ fs := flag.NewFlagSet("workflow submit", flag.ContinueOnError)
547
+ file := fs.String("file", "", "workflow YAML file")
548
+ if err := fs.Parse(args[1:]); err != nil {
549
+ return exitUsage
550
+ }
551
+ if *file == "" {
552
+ fmt.Fprintln(os.Stderr, "workflow submit: --file is required")
553
+ return exitUsage
554
+ }
555
+ body, err := os.ReadFile(*file)
556
+ if err != nil {
557
+ fmt.Fprintln(os.Stderr, err)
558
+ return exitFail
559
+ }
560
+ wf, err := c.SubmitWorkflow(context.Background(), body)
561
+ if err != nil {
562
+ fmt.Fprintln(os.Stderr, err)
563
+ return exitFail
564
+ }
565
+ fmt.Println(wf.Name)
566
+ return exitOK
567
+ case "remove":
568
+ fs := flag.NewFlagSet("workflow remove", flag.ContinueOnError)
569
+ name := fs.String("name", "", "workflow name")
570
+ if err := fs.Parse(args[1:]); err != nil {
571
+ return exitUsage
572
+ }
573
+ if *name == "" {
574
+ fmt.Fprintln(os.Stderr, "workflow remove: --name is required")
575
+ return exitUsage
576
+ }
577
+ if err := c.RemoveWorkflow(context.Background(), *name); err != nil {
578
+ fmt.Fprintln(os.Stderr, err)
579
+ return exitFail
580
+ }
581
+ return exitOK
582
+ case "list":
583
+ fs := flag.NewFlagSet("workflow list", flag.ContinueOnError)
584
+ if err := fs.Parse(args[1:]); err != nil {
585
+ return exitUsage
586
+ }
587
+ wfs, err := c.ListWorkflows(context.Background())
588
+ if err != nil {
589
+ fmt.Fprintln(os.Stderr, err)
590
+ return exitFail
591
+ }
592
+ for _, wf := range wfs {
593
+ fmt.Println(wf.Name)
594
+ }
595
+ return exitOK
596
+ case "get":
597
+ fs := flag.NewFlagSet("workflow get", flag.ContinueOnError)
598
+ name := fs.String("name", "", "workflow name")
599
+ if err := fs.Parse(args[1:]); err != nil {
600
+ return exitUsage
601
+ }
602
+ if *name == "" {
603
+ fmt.Fprintln(os.Stderr, "workflow get: --name is required")
604
+ return exitUsage
605
+ }
606
+ wf, err := c.GetWorkflow(context.Background(), *name)
607
+ if err != nil {
608
+ fmt.Fprintln(os.Stderr, err)
609
+ return exitFail
610
+ }
611
+ enc := json.NewEncoder(os.Stdout)
612
+ enc.SetIndent("", " ")
613
+ _ = enc.Encode(wf)
614
+ return exitOK
615
+ }
616
+ usage(os.Stderr)
617
+ return exitUsage
618
+ }
619
+
620
+ // --- repo ---
621
+
622
+ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
623
+ if len(args) == 0 {
624
+ usage(os.Stderr)
625
+ return exitUsage
626
+ }
627
+ switch args[0] {
628
+ case "register":
629
+ fs := flag.NewFlagSet("repo register", flag.ContinueOnError)
630
+ name := fs.String("name", "", "repo name (non-interactive)")
631
+ path := fs.String("path", "", "repo path (non-interactive)")
632
+ sets := kvFlags{}
633
+ fs.Var(&sets, "set", "required task repo key value, key=value (repeatable)")
634
+ if err := fs.Parse(args[1:]); err != nil {
635
+ return exitUsage
636
+ }
637
+ return cmdRepoRegister(c, *name, *path, sets, stdin)
638
+ case "remove":
639
+ fs := flag.NewFlagSet("repo remove", flag.ContinueOnError)
640
+ name := fs.String("name", "", "repo name")
641
+ if err := fs.Parse(args[1:]); err != nil {
642
+ return exitUsage
643
+ }
644
+ if *name == "" {
645
+ fmt.Fprintln(os.Stderr, "repo remove: --name is required")
646
+ return exitUsage
647
+ }
648
+ if err := c.RemoveRepo(context.Background(), *name); err != nil {
649
+ fmt.Fprintln(os.Stderr, err)
650
+ return exitFail
651
+ }
652
+ return exitOK
653
+ case "list":
654
+ fs := flag.NewFlagSet("repo list", flag.ContinueOnError)
655
+ if err := fs.Parse(args[1:]); err != nil {
656
+ return exitUsage
657
+ }
658
+ infos, err := c.ListRepos(context.Background())
659
+ if err != nil {
660
+ fmt.Fprintln(os.Stderr, err)
661
+ return exitFail
662
+ }
663
+ for _, info := range infos {
664
+ fmt.Println(info.Name)
665
+ }
666
+ return exitOK
667
+ case "get":
668
+ fs := flag.NewFlagSet("repo get", flag.ContinueOnError)
669
+ name := fs.String("name", "", "repo name")
670
+ if err := fs.Parse(args[1:]); err != nil {
671
+ return exitUsage
672
+ }
673
+ if *name == "" {
674
+ fmt.Fprintln(os.Stderr, "repo get: --name is required")
675
+ return exitUsage
676
+ }
677
+ info, err := c.GetRepo(context.Background(), *name)
678
+ if err != nil {
679
+ fmt.Fprintln(os.Stderr, err)
680
+ return exitFail
681
+ }
682
+ enc := json.NewEncoder(os.Stdout)
683
+ enc.SetIndent("", " ")
684
+ _ = enc.Encode(info)
685
+ return exitOK
686
+ }
687
+ usage(os.Stderr)
688
+ return exitUsage
689
+ }
690
+
691
+ // cmdRepoRegister registers one flagged repo or multiple interactively selected
692
+ // runner repos. Jira project is shared and component is always the repo name.
693
+ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
694
+ ctx := context.Background()
695
+ flagged := flagName != "" || flagPath != "" || len(sets) > 0
696
+
697
+ if flagged {
698
+ // Presence validation BEFORE any server contact.
699
+ if flagName == "" || flagPath == "" {
700
+ fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
701
+ return exitUsage
702
+ }
703
+ if _, ok := sets["component"]; ok {
704
+ fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
705
+ return exitUsage
706
+ }
707
+ fields, err := c.RepoTaskFields(ctx)
708
+ if err != nil {
709
+ fmt.Fprintln(os.Stderr, err)
710
+ return exitFail
711
+ }
712
+ taskCfg, err := repoTaskConfig(fields, sets, flagName)
713
+ if err != nil {
714
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
715
+ return exitUsage
716
+ }
717
+ info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
718
+ if err != nil {
719
+ fmt.Fprintln(os.Stderr, err)
720
+ return exitFail
721
+ }
722
+ fmt.Println(info.Name)
723
+ return exitOK
724
+ }
725
+
726
+ fields, err := c.RepoTaskFields(ctx)
239
727
  if err != nil {
240
- log.Fatalf("%v", err)
728
+ fmt.Fprintln(os.Stderr, err)
729
+ return exitFail
241
730
  }
242
- client, err := server.NewClient()
731
+
732
+ if !isTTY(stdin) {
733
+ fmt.Fprintln(os.Stderr, "repo register: interactive registration requires a TTY (or pass --name/--path/--set)")
734
+ return exitFail
735
+ }
736
+ candidates, err := c.DiscoverRepos(ctx)
243
737
  if err != nil {
244
- log.Fatalf("%v", err)
738
+ fmt.Fprintln(os.Stderr, err)
739
+ return exitFail
740
+ }
741
+ if len(candidates) == 0 {
742
+ fmt.Fprintln(os.Stderr, "repo register: runner discovered no repos")
743
+ return exitFail
744
+ }
745
+
746
+ selected := []int{}
747
+ options := make([]huh.Option[int], len(candidates))
748
+ for i, cand := range candidates {
749
+ options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
750
+ }
751
+ pickField := repoMultiSelect(options, &selected)
752
+ pick := huh.NewForm(huh.NewGroup(pickField))
753
+ if err := pick.Run(); err != nil {
754
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
755
+ return exitFail
756
+ }
757
+
758
+ sharedFields := registrationSharedFields(fields)
759
+ values := make([]string, len(sharedFields))
760
+ inputs := make([]huh.Field, len(sharedFields))
761
+ for i, field := range sharedFields {
762
+ title := field
763
+ if field == "project" {
764
+ title = "Jira project"
765
+ }
766
+ inputs[i] = huh.NewInput().Title(title).Value(&values[i])
245
767
  }
246
- if err := client.Submit(cwd, yamlBytes); err != nil {
247
- log.Fatalf("submit: %v", err)
768
+ if len(inputs) > 0 {
769
+ if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
770
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
771
+ return exitFail
772
+ }
773
+ }
774
+ shared := kvFlags{}
775
+ for i, field := range sharedFields {
776
+ shared[field] = values[i]
777
+ }
778
+ if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
779
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
780
+ return exitFail
781
+ }
782
+ return exitOK
783
+ }
784
+
785
+ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
786
+ return huh.NewMultiSelect[int]().
787
+ Title("Select repositories").
788
+ Options(options...).
789
+ Value(selected).
790
+ Validate(func(values []int) error {
791
+ if len(values) == 0 {
792
+ return fmt.Errorf("select at least one repository")
793
+ }
794
+ return nil
795
+ })
796
+ }
797
+
798
+ func registrationSharedFields(fields []string) []string {
799
+ shared := make([]string, 0, len(fields))
800
+ for _, field := range fields {
801
+ if field != "component" {
802
+ shared = append(shared, field)
803
+ }
804
+ }
805
+ return shared
806
+ }
807
+
808
+ func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
809
+ required := map[string]bool{}
810
+ for _, field := range fields {
811
+ required[field] = true
812
+ }
813
+ values := config.RawValues{}
814
+ for key, value := range supplied {
815
+ if !required[key] {
816
+ return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
817
+ }
818
+ if value == "" {
819
+ return nil, fmt.Errorf("task key %q requires a non-empty value", key)
820
+ }
821
+ values[key] = value
822
+ }
823
+ if required["component"] {
824
+ values["component"] = repoName
825
+ }
826
+ var missing []string
827
+ for _, field := range fields {
828
+ if _, ok := values[field]; !ok {
829
+ missing = append(missing, field)
830
+ }
831
+ }
832
+ if len(missing) > 0 {
833
+ return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
834
+ strings.Join(missing, ", "), missing[0])
835
+ }
836
+ return values, nil
837
+ }
838
+
839
+ func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
840
+ for _, index := range selected {
841
+ candidate := candidates[index]
842
+ taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
843
+ if err != nil {
844
+ return fmt.Errorf("%s: %w", candidate.Name, err)
845
+ }
846
+ info, err := c.RegisterRepo(ctx, repo.RegisterInput{
847
+ Name: candidate.Name,
848
+ Path: candidate.Path,
849
+ TaskConfig: taskCfg,
850
+ })
851
+ if err != nil {
852
+ return fmt.Errorf("%s: %w", candidate.Name, err)
853
+ }
854
+ fmt.Println(info.Name)
855
+ }
856
+ return nil
857
+ }
858
+
859
+ // kvFlags collects repeated key=value flags (registration task config).
860
+ // Parse rejects malformed pairs, empty keys/values, and duplicates.
861
+ type kvFlags map[string]string
862
+
863
+ func (k kvFlags) String() string { return "" }
864
+ func (k kvFlags) Set(s string) error {
865
+ kv := strings.SplitN(s, "=", 2)
866
+ if len(kv) != 2 || kv[0] == "" || kv[1] == "" {
867
+ return fmt.Errorf("expected key=value with non-empty key and value, got %q", s)
868
+ }
869
+ if _, dup := k[kv[0]]; dup {
870
+ return fmt.Errorf("duplicate --set for key %q", kv[0])
871
+ }
872
+ k[kv[0]] = kv[1]
873
+ return nil
874
+ }
875
+
876
+ // --- run ---
877
+
878
+ func cmdRun(c *server.Client, args []string) int {
879
+ if len(args) == 0 {
880
+ usage(os.Stderr)
881
+ return exitUsage
882
+ }
883
+ switch args[0] {
884
+ case "list":
885
+ fs := flag.NewFlagSet("run list", flag.ContinueOnError)
886
+ repoName := fs.String("repo", "", "filter by repo")
887
+ workflowName := fs.String("workflow", "", "filter by workflow")
888
+ ticket := fs.String("ticket", "", "filter by ticket")
889
+ if err := fs.Parse(args[1:]); err != nil {
890
+ return exitUsage
891
+ }
892
+ runs, err := c.ListRuns(context.Background(), runsvc.Filter{
893
+ Repo: *repoName,
894
+ Workflow: *workflowName,
895
+ Ticket: *ticket,
896
+ })
897
+ if err != nil {
898
+ fmt.Fprintln(os.Stderr, err)
899
+ return exitFail
900
+ }
901
+ for _, r := range runs {
902
+ fmt.Println(formatRunListRow(r))
903
+ }
904
+ return exitOK
905
+ case "get":
906
+ fs := flag.NewFlagSet("run get", flag.ContinueOnError)
907
+ ticket := fs.String("ticket", "", "ticket key")
908
+ if err := fs.Parse(args[1:]); err != nil {
909
+ return exitUsage
910
+ }
911
+ if *ticket == "" {
912
+ fmt.Fprintln(os.Stderr, "run get: --ticket is required")
913
+ return exitUsage
914
+ }
915
+ rn, err := c.GetRunByTicket(context.Background(), *ticket)
916
+ if err != nil {
917
+ fmt.Fprintln(os.Stderr, err)
918
+ return exitFail
919
+ }
920
+ enc := json.NewEncoder(os.Stdout)
921
+ enc.SetIndent("", " ")
922
+ _ = enc.Encode(rn)
923
+ return exitOK
924
+ case "cancel":
925
+ fs := flag.NewFlagSet("run cancel", flag.ContinueOnError)
926
+ ticket := fs.String("ticket", "", "ticket key")
927
+ reason := fs.String("reason", "", "cancel reason")
928
+ if err := fs.Parse(args[1:]); err != nil {
929
+ return exitUsage
930
+ }
931
+ if *ticket == "" {
932
+ fmt.Fprintln(os.Stderr, "run cancel: --ticket is required")
933
+ return exitUsage
934
+ }
935
+ if err := c.CancelRun(context.Background(), *ticket, *reason); err != nil {
936
+ fmt.Fprintln(os.Stderr, err)
937
+ return exitFail
938
+ }
939
+ return exitOK
940
+ }
941
+ usage(os.Stderr)
942
+ return exitUsage
943
+ }
944
+
945
+ func formatRunListRow(r runsvc.Run) string {
946
+ row := fmt.Sprintf("%s\t%s\t%s\t%s", r.ID, r.Ticket.Key, r.Workflow, r.State)
947
+ if r.Retry != nil {
948
+ row += fmt.Sprintf("\tretrying attempt=%d next=%s error=%q",
949
+ r.Retry.Attempt, r.Retry.NextRetryAt.Format(time.RFC3339), r.Retry.LastError)
248
950
  }
249
- fmt.Println("submitted")
951
+ return row
250
952
  }