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