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.
- package/README.md +160 -143
- package/cmd/relay-flow/commands_test.go +968 -0
- package/cmd/relay-flow/main.go +877 -175
- package/cmd/relay-flow/scenario_test.go +1310 -0
- package/cmd/relay-flow/serve.go +610 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +520 -0
- package/internal/execution/goworkflows/engine_test.go +660 -0
- package/internal/execution/goworkflows/fakes_test.go +509 -0
- package/internal/execution/goworkflows/interpreter.go +613 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +133 -0
- package/internal/execution/goworkflows/node_runtime_test.go +510 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +174 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +69 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +132 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +84 -0
- package/internal/router/router_test.go +231 -0
- package/internal/run/manager.go +121 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +286 -0
- package/internal/runner/contract_test.go +259 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +341 -172
- package/internal/runner/orca/orca_test.go +256 -143
- package/internal/runner/orca/orcacli/orcacli.go +220 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +157 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +32 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +54 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/auth_test.go +48 -0
- package/internal/task/contract_test.go +225 -0
- package/internal/task/factory.go +119 -0
- package/internal/task/jira/auth.go +183 -0
- package/internal/task/jira/auth_test.go +107 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +254 -0
- package/internal/task/jira/helpers_test.go +70 -0
- package/internal/task/jira/jira.go +538 -0
- package/internal/task/jira/normalize.go +119 -0
- package/internal/task/jira/rest/adf.go +128 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/testdata/jira_search_issues.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +158 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +345 -0
- package/internal/workflow/workflow_test.go +412 -0
- package/package.json +7 -2
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
package/cmd/relay-flow/main.go
CHANGED
|
@@ -1,250 +1,952 @@
|
|
|
1
|
-
// Command relay-flow
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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/
|
|
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/
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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(
|
|
35
|
-
case "stop":
|
|
36
|
-
cmdStop(os.Args[2:])
|
|
90
|
+
return cmdInit(p, args[1:], stdin)
|
|
37
91
|
case "serve":
|
|
38
|
-
cmdServe(
|
|
39
|
-
case "
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
usage(os.Stderr)
|
|
125
|
+
return exitUsage
|
|
46
126
|
}
|
|
47
127
|
}
|
|
48
128
|
|
|
49
|
-
func usage() {
|
|
50
|
-
fmt.Fprintln(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
180
|
+
fmt.Fprintln(os.Stderr, err)
|
|
181
|
+
return exitFail
|
|
65
182
|
}
|
|
66
|
-
|
|
183
|
+
databaseExists, err := pathExists(p.Database)
|
|
67
184
|
if err != nil {
|
|
68
|
-
|
|
185
|
+
fmt.Fprintln(os.Stderr, err)
|
|
186
|
+
return exitFail
|
|
69
187
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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 :=
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
110
|
-
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
+
return nil, fmt.Errorf("open server lock: %w", err)
|
|
126
301
|
}
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
333
|
+
return nil, err
|
|
136
334
|
}
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
383
|
+
return cmdTaskAuth(p, args[1:], stdin)
|
|
384
|
+
}
|
|
163
385
|
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
441
|
+
return fmt.Errorf("serve --background: resolve executable: %w", err)
|
|
209
442
|
}
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
+
fmt.Fprintln(os.Stderr, err)
|
|
521
|
+
return exitFail
|
|
522
|
+
}
|
|
523
|
+
var registration runsvc.NodeRuntimeRegistration
|
|
524
|
+
if err := json.Unmarshal(body, ®istration); 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
|
-
|
|
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
|
-
|
|
728
|
+
fmt.Fprintln(os.Stderr, err)
|
|
729
|
+
return exitFail
|
|
241
730
|
}
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
951
|
+
return row
|
|
250
952
|
}
|