relay-flow 0.2.0-alpha → 0.2.2-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.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
package/cmd/relay-flow/main.go
CHANGED
|
@@ -12,7 +12,9 @@ import (
|
|
|
12
12
|
"flag"
|
|
13
13
|
"fmt"
|
|
14
14
|
"io"
|
|
15
|
+
"log/slog"
|
|
15
16
|
"os"
|
|
17
|
+
"os/exec"
|
|
16
18
|
"os/signal"
|
|
17
19
|
"path/filepath"
|
|
18
20
|
"strings"
|
|
@@ -36,6 +38,7 @@ import (
|
|
|
36
38
|
// name validation at init-time).
|
|
37
39
|
_ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
|
|
38
40
|
_ "github.com/rajpopat27/relay-flow/internal/runner/orca"
|
|
41
|
+
_ "github.com/rajpopat27/relay-flow/internal/task/beads"
|
|
39
42
|
_ "github.com/rajpopat27/relay-flow/internal/task/jira"
|
|
40
43
|
)
|
|
41
44
|
|
|
@@ -55,14 +58,15 @@ func home() (paths.Paths, error) {
|
|
|
55
58
|
if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
|
|
56
59
|
root := env
|
|
57
60
|
return paths.Paths{
|
|
58
|
-
Root:
|
|
59
|
-
Config:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
Root: root,
|
|
62
|
+
Config: filepath.Join(root, "config.yaml"),
|
|
63
|
+
Credentials: filepath.Join(root, "credentials.yaml"),
|
|
64
|
+
Workflows: filepath.Join(root, "workflows"),
|
|
65
|
+
Database: filepath.Join(root, "state.db"),
|
|
66
|
+
Socket: filepath.Join(root, "server.sock"),
|
|
67
|
+
Lock: filepath.Join(root, "server.lock"),
|
|
68
|
+
ServerLog: filepath.Join(root, "server.log"),
|
|
69
|
+
PluginLog: filepath.Join(root, "plugin.log"),
|
|
66
70
|
}, nil
|
|
67
71
|
}
|
|
68
72
|
return paths.ForUserHome()
|
|
@@ -109,6 +113,8 @@ func run(args []string, stdin io.Reader) int {
|
|
|
109
113
|
return exitUsage
|
|
110
114
|
}
|
|
111
115
|
return cmdRuntimeRegister(client, stdin)
|
|
116
|
+
case "task":
|
|
117
|
+
return cmdTask(p, args[1:], stdin)
|
|
112
118
|
case "workflow":
|
|
113
119
|
return cmdWorkflow(client, args[1:])
|
|
114
120
|
case "repo":
|
|
@@ -125,8 +131,9 @@ func usage(w io.Writer) {
|
|
|
125
131
|
fmt.Fprintln(w, `relay-flow — durable ticket runner
|
|
126
132
|
|
|
127
133
|
Usage:
|
|
128
|
-
relay-flow init [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
|
|
129
|
-
relay-flow
|
|
134
|
+
relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
|
|
135
|
+
relay-flow task auth [task-plugin options]
|
|
136
|
+
relay-flow serve [--recover] [--debug] [--background]
|
|
130
137
|
relay-flow stop
|
|
131
138
|
relay-flow report
|
|
132
139
|
|
|
@@ -149,7 +156,8 @@ Usage:
|
|
|
149
156
|
|
|
150
157
|
// cmdInit is the init composition root (docs lines 950/1028): select the
|
|
151
158
|
// three plugin names, atomically write the machine config, and initialize
|
|
152
|
-
// the SQLite database. Refuses to overwrite existing config or history
|
|
159
|
+
// the SQLite database. Refuses to overwrite existing config or history unless
|
|
160
|
+
// --force safely updates plugin selections while preserving durable state.
|
|
153
161
|
// Plugin selection precedence: --task-plugin/--runner-plugin/--harness-plugin
|
|
154
162
|
// flags (all three required for a fully non-interactive run) → huh form on
|
|
155
163
|
// a TTY → three stdin lines (task, runner, harness), the documented test
|
|
@@ -159,6 +167,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
159
167
|
taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
|
|
160
168
|
runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
|
|
161
169
|
harnessName := fs.String("harness-plugin", "", "harness plugin name (non-interactive)")
|
|
170
|
+
force := fs.Bool("force", false, "update plugin selections while preserving existing state")
|
|
162
171
|
if err := fs.Parse(args); err != nil {
|
|
163
172
|
return exitUsage
|
|
164
173
|
}
|
|
@@ -167,20 +176,53 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
167
176
|
fmt.Fprintln(os.Stderr, "init: --task-plugin, --runner-plugin, and --harness-plugin must be given together")
|
|
168
177
|
return exitUsage
|
|
169
178
|
}
|
|
170
|
-
|
|
171
|
-
if
|
|
179
|
+
configExists, err := pathExists(p.Config)
|
|
180
|
+
if err != nil {
|
|
181
|
+
fmt.Fprintln(os.Stderr, err)
|
|
182
|
+
return exitFail
|
|
183
|
+
}
|
|
184
|
+
databaseExists, err := pathExists(p.Database)
|
|
185
|
+
if err != nil {
|
|
186
|
+
fmt.Fprintln(os.Stderr, err)
|
|
187
|
+
return exitFail
|
|
188
|
+
}
|
|
189
|
+
if !*force && configExists {
|
|
172
190
|
fmt.Fprintln(os.Stderr, "init: already initialized (config exists): "+p.Config)
|
|
173
191
|
return exitFail
|
|
174
192
|
}
|
|
175
|
-
if
|
|
193
|
+
if !*force && databaseExists {
|
|
176
194
|
fmt.Fprintln(os.Stderr, "init: already initialized (database exists): "+p.Database)
|
|
177
195
|
return exitFail
|
|
178
196
|
}
|
|
197
|
+
if *force && configExists && !databaseExists {
|
|
198
|
+
fmt.Fprintln(os.Stderr, "init: database is missing; refusing to recreate existing durable state")
|
|
199
|
+
return exitFail
|
|
200
|
+
}
|
|
179
201
|
if err := os.MkdirAll(p.Root, 0o700); err != nil {
|
|
180
202
|
fmt.Fprintln(os.Stderr, err)
|
|
181
203
|
return exitFail
|
|
182
204
|
}
|
|
183
205
|
_ = os.Chmod(p.Root, 0o700)
|
|
206
|
+
var unlock func()
|
|
207
|
+
if *force {
|
|
208
|
+
unlock, err = lockForForcedInit(p.Lock)
|
|
209
|
+
if err != nil {
|
|
210
|
+
fmt.Fprintln(os.Stderr, "init: "+err.Error())
|
|
211
|
+
return exitFail
|
|
212
|
+
}
|
|
213
|
+
defer unlock()
|
|
214
|
+
if databaseExists {
|
|
215
|
+
active, err := goworkflows.HasNonterminalRuns(p.Database)
|
|
216
|
+
if err != nil {
|
|
217
|
+
fmt.Fprintln(os.Stderr, "init: "+err.Error())
|
|
218
|
+
return exitFail
|
|
219
|
+
}
|
|
220
|
+
if active {
|
|
221
|
+
fmt.Fprintln(os.Stderr, "init: cannot use --force while a run is nonterminal")
|
|
222
|
+
return exitFail
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
184
226
|
|
|
185
227
|
// Selection precedence: flags → TTY form → stdin lines. The stdin path
|
|
186
228
|
// is the documented test seam and script path.
|
|
@@ -196,9 +238,9 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
196
238
|
return exitFail
|
|
197
239
|
}
|
|
198
240
|
default:
|
|
199
|
-
names =
|
|
241
|
+
names = readInitLines(stdin)
|
|
200
242
|
if names == nil {
|
|
201
|
-
fmt.Fprintln(os.Stderr, "init: expected
|
|
243
|
+
fmt.Fprintln(os.Stderr, "init: expected task, runner, and harness plugin selections on stdin")
|
|
202
244
|
return exitFail
|
|
203
245
|
}
|
|
204
246
|
}
|
|
@@ -217,83 +259,184 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
217
259
|
return exitFail
|
|
218
260
|
}
|
|
219
261
|
|
|
220
|
-
cfg := &config.Machine{
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
262
|
+
cfg := &config.Machine{KeepTerminalsAlive: true, KeepSessionsAlive: true}
|
|
263
|
+
if *force && configExists {
|
|
264
|
+
cfg, err = config.LoadMachine(p.Config)
|
|
265
|
+
if err != nil {
|
|
266
|
+
fmt.Fprintln(os.Stderr, err)
|
|
267
|
+
return exitFail
|
|
268
|
+
}
|
|
226
269
|
}
|
|
227
|
-
|
|
228
|
-
|
|
270
|
+
cfg.TaskPlugin = names[0]
|
|
271
|
+
cfg.RunnerPlugin = names[1]
|
|
272
|
+
cfg.HarnessPlugin = names[2]
|
|
273
|
+
taskDefaults, err := task.Defaults(names[0])
|
|
274
|
+
if err != nil {
|
|
275
|
+
fmt.Fprintln(os.Stderr, "init: task plugin: "+err.Error())
|
|
229
276
|
return exitFail
|
|
230
277
|
}
|
|
231
|
-
if
|
|
278
|
+
if !*force || !configExists {
|
|
279
|
+
cfg.TaskConfig = taskDefaults
|
|
280
|
+
} else {
|
|
281
|
+
cfg.TaskConfig = config.Merge(taskDefaults, cfg.TaskConfig)
|
|
282
|
+
}
|
|
283
|
+
if err := task.ValidateTextConfig(names[0], cfg.TaskConfig); err != nil {
|
|
284
|
+
fmt.Fprintln(os.Stderr, "init: task plugin config: "+err.Error())
|
|
285
|
+
return exitFail
|
|
286
|
+
}
|
|
287
|
+
harnessDefaults, err := harness.Defaults(names[2])
|
|
288
|
+
if err != nil {
|
|
289
|
+
fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
|
|
290
|
+
return exitFail
|
|
291
|
+
}
|
|
292
|
+
if !*force || !configExists {
|
|
293
|
+
cfg.HarnessConfig = harnessDefaults
|
|
294
|
+
} else {
|
|
295
|
+
cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
|
|
296
|
+
}
|
|
297
|
+
if err := config.SaveMachine(p.Config, cfg); err != nil {
|
|
232
298
|
fmt.Fprintln(os.Stderr, err)
|
|
233
299
|
return exitFail
|
|
234
300
|
}
|
|
301
|
+
if !databaseExists {
|
|
302
|
+
if err := goworkflows.InitDatabase(p.Database); err != nil {
|
|
303
|
+
fmt.Fprintln(os.Stderr, err)
|
|
304
|
+
return exitFail
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
fmt.Printf("Task system: %s\nRunner: %s\nHarness: %s\nRelay-flow initialized\n", names[0], names[1], names[2])
|
|
235
308
|
return exitOK
|
|
236
309
|
}
|
|
237
310
|
|
|
311
|
+
func pathExists(path string) (bool, error) {
|
|
312
|
+
_, err := os.Stat(path)
|
|
313
|
+
if err == nil {
|
|
314
|
+
return true, nil
|
|
315
|
+
}
|
|
316
|
+
if os.IsNotExist(err) {
|
|
317
|
+
return false, nil
|
|
318
|
+
}
|
|
319
|
+
return false, fmt.Errorf("stat %s: %w", path, err)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func lockForForcedInit(path string) (func(), error) {
|
|
323
|
+
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
|
324
|
+
if err != nil {
|
|
325
|
+
return nil, fmt.Errorf("open server lock: %w", err)
|
|
326
|
+
}
|
|
327
|
+
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
328
|
+
f.Close()
|
|
329
|
+
return nil, fmt.Errorf("cannot use --force while the server is running")
|
|
330
|
+
}
|
|
331
|
+
return func() {
|
|
332
|
+
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
333
|
+
_ = f.Close()
|
|
334
|
+
}, nil
|
|
335
|
+
}
|
|
336
|
+
|
|
238
337
|
// isTTY reports whether stdin is an interactive terminal.
|
|
239
338
|
func isTTY(stdin io.Reader) bool {
|
|
240
339
|
f, ok := stdin.(*os.File)
|
|
241
340
|
return ok && isatty.IsTerminal(f.Fd())
|
|
242
341
|
}
|
|
243
342
|
|
|
244
|
-
// pickPluginsInteractive runs
|
|
245
|
-
// plugin (task, runner, harness); enter advances/submits.
|
|
343
|
+
// pickPluginsInteractive runs one searchable select per plugin type.
|
|
246
344
|
func pickPluginsInteractive() ([]string, error) {
|
|
247
345
|
names := make([]string, 3)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
346
|
+
groups := make([]*huh.Group, 0, 3)
|
|
347
|
+
for _, selection := range []struct {
|
|
348
|
+
title string
|
|
349
|
+
options []string
|
|
350
|
+
value *string
|
|
351
|
+
}{
|
|
352
|
+
{"Select task system", task.Names(), &names[0]},
|
|
353
|
+
{"Select runner", runner.Names(), &names[1]},
|
|
354
|
+
{"Select harness", harness.Names(), &names[2]},
|
|
355
|
+
} {
|
|
356
|
+
field, err := pluginSelectField(selection.title, selection.options, selection.value)
|
|
357
|
+
if err != nil {
|
|
358
|
+
return nil, err
|
|
359
|
+
}
|
|
360
|
+
if field != nil {
|
|
361
|
+
groups = append(groups, huh.NewGroup(field))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if len(groups) == 0 {
|
|
365
|
+
return names, nil
|
|
366
|
+
}
|
|
367
|
+
form := huh.NewForm(groups...)
|
|
265
368
|
if err := form.Run(); err != nil {
|
|
266
369
|
return nil, err
|
|
267
370
|
}
|
|
268
371
|
return names, nil
|
|
269
372
|
}
|
|
270
373
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
374
|
+
func pluginSelectField(title string, options []string, value *string) (huh.Field, error) {
|
|
375
|
+
if len(options) == 0 {
|
|
376
|
+
return nil, fmt.Errorf("%s: no plugins registered", title)
|
|
377
|
+
}
|
|
378
|
+
return huh.NewSelect[string]().
|
|
379
|
+
Title(title).
|
|
380
|
+
Description("Press / to filter available plugins.").
|
|
381
|
+
Options(huh.NewOptions(options...)...).
|
|
382
|
+
Value(value), nil
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// readInitLines reads the three plugin selections.
|
|
386
|
+
func readInitLines(stdin io.Reader) []string {
|
|
387
|
+
values := make([]string, 0, 3)
|
|
276
388
|
scanner := bufio.NewScanner(stdin)
|
|
277
|
-
for len(
|
|
389
|
+
for len(values) < 3 && scanner.Scan() {
|
|
278
390
|
line := strings.TrimSpace(scanner.Text())
|
|
279
391
|
if line == "" {
|
|
280
392
|
continue
|
|
281
393
|
}
|
|
282
|
-
|
|
394
|
+
values = append(values, line)
|
|
283
395
|
}
|
|
284
|
-
if len(
|
|
396
|
+
if len(values) < 3 {
|
|
285
397
|
return nil
|
|
286
398
|
}
|
|
287
|
-
return
|
|
399
|
+
return values
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// cmdTask dispatches task-system commands to the selected task plugin.
|
|
403
|
+
func cmdTask(p paths.Paths, args []string, stdin io.Reader) int {
|
|
404
|
+
if len(args) == 0 || args[0] != "auth" {
|
|
405
|
+
usage(os.Stderr)
|
|
406
|
+
return exitUsage
|
|
407
|
+
}
|
|
408
|
+
return cmdTaskAuth(p, args[1:], stdin)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func cmdTaskAuth(p paths.Paths, args []string, stdin io.Reader) int {
|
|
412
|
+
cfg, err := config.LoadMachine(p.Config)
|
|
413
|
+
if err != nil {
|
|
414
|
+
fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
|
|
415
|
+
return exitFail
|
|
416
|
+
}
|
|
417
|
+
if err := task.Auth(context.Background(), cfg.TaskPlugin, args, stdin); err != nil {
|
|
418
|
+
fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
|
|
419
|
+
return exitFail
|
|
420
|
+
}
|
|
421
|
+
return exitOK
|
|
288
422
|
}
|
|
289
423
|
|
|
290
424
|
func cmdServe(p paths.Paths, args []string) int {
|
|
291
425
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
|
292
426
|
recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
|
|
293
427
|
debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
|
|
428
|
+
background := fs.Bool("background", false, "start the server detached and return after readiness")
|
|
294
429
|
if err := fs.Parse(args); err != nil {
|
|
295
430
|
return exitUsage
|
|
296
431
|
}
|
|
432
|
+
if *background {
|
|
433
|
+
if err := startBackgroundServe(p, *recover, *debug); err != nil {
|
|
434
|
+
fmt.Fprintln(os.Stderr, err)
|
|
435
|
+
return exitFail
|
|
436
|
+
}
|
|
437
|
+
fmt.Println("Relay-flow server started")
|
|
438
|
+
return exitOK
|
|
439
|
+
}
|
|
297
440
|
logCloser, err := logging.Setup(p.ServerLog, logging.Options{
|
|
298
441
|
Debug: *debug,
|
|
299
442
|
Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
|
|
@@ -306,12 +449,69 @@ func cmdServe(p paths.Paths, args []string) int {
|
|
|
306
449
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
307
450
|
defer stop()
|
|
308
451
|
if err := serveRoot(ctx, p, *recover); err != nil {
|
|
452
|
+
slog.Error("server startup failed", "error", err)
|
|
309
453
|
fmt.Fprintln(os.Stderr, err)
|
|
310
454
|
return exitFail
|
|
311
455
|
}
|
|
312
456
|
return exitOK
|
|
313
457
|
}
|
|
314
458
|
|
|
459
|
+
func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
460
|
+
client := server.NewClient(p.Socket)
|
|
461
|
+
if serverResponding(client, 200*time.Millisecond) {
|
|
462
|
+
return fmt.Errorf("serve --background: server is already running")
|
|
463
|
+
}
|
|
464
|
+
executable, err := os.Executable()
|
|
465
|
+
if err != nil {
|
|
466
|
+
return fmt.Errorf("serve --background: resolve executable: %w", err)
|
|
467
|
+
}
|
|
468
|
+
childArgs := []string{"serve"}
|
|
469
|
+
if recover {
|
|
470
|
+
childArgs = append(childArgs, "--recover")
|
|
471
|
+
}
|
|
472
|
+
if debug {
|
|
473
|
+
childArgs = append(childArgs, "--debug")
|
|
474
|
+
}
|
|
475
|
+
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
|
|
476
|
+
if err != nil {
|
|
477
|
+
return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
|
|
478
|
+
}
|
|
479
|
+
defer devNull.Close()
|
|
480
|
+
cmd := exec.Command(executable, childArgs...)
|
|
481
|
+
cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
|
|
482
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
483
|
+
if err := cmd.Start(); err != nil {
|
|
484
|
+
return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
|
|
485
|
+
}
|
|
486
|
+
wait := make(chan error, 1)
|
|
487
|
+
go func() { wait <- cmd.Wait() }()
|
|
488
|
+
deadline := time.NewTimer(10 * time.Second)
|
|
489
|
+
defer deadline.Stop()
|
|
490
|
+
ticker := time.NewTicker(50 * time.Millisecond)
|
|
491
|
+
defer ticker.Stop()
|
|
492
|
+
for {
|
|
493
|
+
select {
|
|
494
|
+
case err := <-wait:
|
|
495
|
+
return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", err, p.ServerLog)
|
|
496
|
+
case <-deadline.C:
|
|
497
|
+
_ = cmd.Process.Kill()
|
|
498
|
+
<-wait
|
|
499
|
+
return fmt.Errorf("serve --background: startup timed out; see %s", p.ServerLog)
|
|
500
|
+
case <-ticker.C:
|
|
501
|
+
if serverResponding(client, 200*time.Millisecond) {
|
|
502
|
+
return nil
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
func serverResponding(client *server.Client, timeout time.Duration) bool {
|
|
509
|
+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
510
|
+
defer cancel()
|
|
511
|
+
_, err := client.ListRepos(ctx)
|
|
512
|
+
return err == nil
|
|
513
|
+
}
|
|
514
|
+
|
|
315
515
|
// --- report ---
|
|
316
516
|
|
|
317
517
|
// cmdReport reads one JSON object from stdin and posts it to /reports.
|
|
@@ -513,12 +713,8 @@ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
|
|
|
513
713
|
return exitUsage
|
|
514
714
|
}
|
|
515
715
|
|
|
516
|
-
// cmdRepoRegister registers one repo
|
|
517
|
-
//
|
|
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.
|
|
716
|
+
// cmdRepoRegister registers one flagged repo or multiple interactively selected
|
|
717
|
+
// runner repos. Jira project is shared and component is always the repo name.
|
|
522
718
|
func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
|
|
523
719
|
ctx := context.Background()
|
|
524
720
|
flagged := flagName != "" || flagPath != "" || len(sets) > 0
|
|
@@ -529,39 +725,18 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
|
|
|
529
725
|
fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
|
|
530
726
|
return exitUsage
|
|
531
727
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
fmt.Fprintf(os.Stderr, "repo register: --set %s requires a non-empty value\n", k)
|
|
536
|
-
return exitUsage
|
|
537
|
-
}
|
|
538
|
-
taskCfg[k] = v
|
|
728
|
+
if _, ok := sets["component"]; ok {
|
|
729
|
+
fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
|
|
730
|
+
return exitUsage
|
|
539
731
|
}
|
|
540
732
|
fields, err := c.RepoTaskFields(ctx)
|
|
541
733
|
if err != nil {
|
|
542
734
|
fmt.Fprintln(os.Stderr, err)
|
|
543
735
|
return exitFail
|
|
544
736
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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])
|
|
737
|
+
taskCfg, err := repoTaskConfig(fields, sets, flagName)
|
|
738
|
+
if err != nil {
|
|
739
|
+
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
565
740
|
return exitUsage
|
|
566
741
|
}
|
|
567
742
|
info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
|
|
@@ -593,55 +768,117 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
|
|
|
593
768
|
return exitFail
|
|
594
769
|
}
|
|
595
770
|
|
|
596
|
-
|
|
597
|
-
selected := 0
|
|
771
|
+
selected := []int{}
|
|
598
772
|
options := make([]huh.Option[int], len(candidates))
|
|
599
773
|
for i, cand := range candidates {
|
|
600
774
|
options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
|
|
601
775
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
Options(options...).
|
|
605
|
-
Filtering(true).
|
|
606
|
-
Value(&selected)))
|
|
776
|
+
pickField := repoMultiSelect(options, &selected)
|
|
777
|
+
pick := huh.NewForm(huh.NewGroup(pickField))
|
|
607
778
|
if err := pick.Run(); err != nil {
|
|
608
779
|
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
609
780
|
return exitFail
|
|
610
781
|
}
|
|
611
782
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
huh.NewInput().Title(
|
|
783
|
+
sharedFields := registrationSharedFields(fields)
|
|
784
|
+
values := make([]string, len(sharedFields))
|
|
785
|
+
inputs := make([]huh.Field, len(sharedFields))
|
|
786
|
+
for i, field := range sharedFields {
|
|
787
|
+
title := field
|
|
788
|
+
if field == "project" {
|
|
789
|
+
title = "Jira project"
|
|
790
|
+
}
|
|
791
|
+
inputs[i] = huh.NewInput().Title(title).Value(&values[i])
|
|
792
|
+
}
|
|
793
|
+
if len(inputs) > 0 {
|
|
794
|
+
if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
|
|
795
|
+
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
796
|
+
return exitFail
|
|
797
|
+
}
|
|
621
798
|
}
|
|
622
|
-
|
|
623
|
-
for i,
|
|
624
|
-
|
|
799
|
+
shared := kvFlags{}
|
|
800
|
+
for i, field := range sharedFields {
|
|
801
|
+
shared[field] = values[i]
|
|
625
802
|
}
|
|
626
|
-
if err :=
|
|
803
|
+
if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
|
|
627
804
|
fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
|
|
628
805
|
return exitFail
|
|
629
806
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
807
|
+
return exitOK
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
|
|
811
|
+
return huh.NewMultiSelect[int]().
|
|
812
|
+
Title("Select repositories").
|
|
813
|
+
Options(options...).
|
|
814
|
+
Value(selected).
|
|
815
|
+
Validate(func(values []int) error {
|
|
816
|
+
if len(values) == 0 {
|
|
817
|
+
return fmt.Errorf("select at least one repository")
|
|
818
|
+
}
|
|
819
|
+
return nil
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
func registrationSharedFields(fields []string) []string {
|
|
824
|
+
shared := make([]string, 0, len(fields))
|
|
825
|
+
for _, field := range fields {
|
|
826
|
+
if field != "component" {
|
|
827
|
+
shared = append(shared, field)
|
|
828
|
+
}
|
|
633
829
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
return exitFail
|
|
830
|
+
return shared
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
|
|
834
|
+
required := map[string]bool{}
|
|
835
|
+
for _, field := range fields {
|
|
836
|
+
required[field] = true
|
|
642
837
|
}
|
|
643
|
-
|
|
644
|
-
|
|
838
|
+
values := config.RawValues{}
|
|
839
|
+
for key, value := range supplied {
|
|
840
|
+
if !required[key] {
|
|
841
|
+
return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
|
|
842
|
+
}
|
|
843
|
+
if value == "" {
|
|
844
|
+
return nil, fmt.Errorf("task key %q requires a non-empty value", key)
|
|
845
|
+
}
|
|
846
|
+
values[key] = value
|
|
847
|
+
}
|
|
848
|
+
if required["component"] {
|
|
849
|
+
values["component"] = repoName
|
|
850
|
+
}
|
|
851
|
+
var missing []string
|
|
852
|
+
for _, field := range fields {
|
|
853
|
+
if _, ok := values[field]; !ok {
|
|
854
|
+
missing = append(missing, field)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if len(missing) > 0 {
|
|
858
|
+
return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
|
|
859
|
+
strings.Join(missing, ", "), missing[0])
|
|
860
|
+
}
|
|
861
|
+
return values, nil
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
|
|
865
|
+
for _, index := range selected {
|
|
866
|
+
candidate := candidates[index]
|
|
867
|
+
taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
|
|
868
|
+
if err != nil {
|
|
869
|
+
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
870
|
+
}
|
|
871
|
+
info, err := c.RegisterRepo(ctx, repo.RegisterInput{
|
|
872
|
+
Name: candidate.Name,
|
|
873
|
+
Path: candidate.Path,
|
|
874
|
+
TaskConfig: taskCfg,
|
|
875
|
+
})
|
|
876
|
+
if err != nil {
|
|
877
|
+
return fmt.Errorf("%s: %w", candidate.Name, err)
|
|
878
|
+
}
|
|
879
|
+
fmt.Println(info.Name)
|
|
880
|
+
}
|
|
881
|
+
return nil
|
|
645
882
|
}
|
|
646
883
|
|
|
647
884
|
// kvFlags collects repeated key=value flags (registration task config).
|