relay-flow 0.3.9-alpha → 0.3.11-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 +30 -34
- package/cmd/relay-flow/commands_test.go +449 -23
- package/cmd/relay-flow/main.go +207 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/examples/config-reference.yaml +23 -19
- package/examples/workflow-reference.yaml +3 -0
- package/internal/execution/goworkflows/activities.go +13 -22
- package/internal/execution/goworkflows/engine_test.go +17 -3
- package/internal/execution/goworkflows/fakes_test.go +20 -0
- package/internal/execution/goworkflows/interpreter.go +25 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/recovery_test.go +81 -1
- package/internal/execution/temporal/activities.go +13 -22
- package/internal/execution/temporal/compact_mailbox_test.go +56 -0
- package/internal/execution/temporal/interpreter.go +16 -3
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +23 -10
- package/internal/harness/opencode/opencode.go +22 -4
- package/internal/harness/opencode/opencode_test.go +41 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +2 -2
- package/internal/harness/pi/pi.go +21 -2
- package/internal/harness/pi/pi_test.go +7 -4
- package/internal/harness/pi/prompt_test.go +13 -5
- package/internal/harness/pi/task_env_test.go +18 -2
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +244 -15
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +67 -0
- package/internal/workflow/report.go +10 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- package/package.json +1 -1
package/cmd/relay-flow/main.go
CHANGED
|
@@ -61,13 +61,25 @@ var (
|
|
|
61
61
|
buildDate = "unknown"
|
|
62
62
|
)
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
var runningAsMain = false
|
|
65
|
+
|
|
66
|
+
const serveChildEnvironment = "RELAY_FLOW_SERVE_CHILD"
|
|
67
|
+
|
|
68
|
+
func main() {
|
|
69
|
+
runningAsMain = true
|
|
70
|
+
os.Exit(run(os.Args[1:], os.Stdin))
|
|
71
|
+
}
|
|
65
72
|
|
|
66
73
|
// home returns the relay-flow root, honoring RELAY_FLOW_HOME (tests and
|
|
67
|
-
// non-standard installs) and falling back to ~/.relay-flow.
|
|
74
|
+
// non-standard installs) and falling back to ~/.relay-flow. The configured
|
|
75
|
+
// root is made absolute so detached children never resolve it through a
|
|
76
|
+
// caller directory that may disappear after launch.
|
|
68
77
|
func home() (paths.Paths, error) {
|
|
69
78
|
if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
|
|
70
|
-
root := env
|
|
79
|
+
root, err := filepath.Abs(env)
|
|
80
|
+
if err != nil {
|
|
81
|
+
return paths.Paths{}, fmt.Errorf("resolve RELAY_FLOW_HOME: %w", err)
|
|
82
|
+
}
|
|
71
83
|
return paths.Paths{
|
|
72
84
|
Root: root,
|
|
73
85
|
Config: filepath.Join(root, "config.yaml"),
|
|
@@ -150,6 +162,7 @@ func usage(w io.Writer) {
|
|
|
150
162
|
|
|
151
163
|
Usage:
|
|
152
164
|
relay-flow [command] [flags]
|
|
165
|
+
rf [command] [flags] (equivalent executable name)
|
|
153
166
|
|
|
154
167
|
Setup and server:
|
|
155
168
|
relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
|
|
@@ -157,8 +170,13 @@ Setup and server:
|
|
|
157
170
|
[--temporal-address <host:port>] [--temporal-namespace <name>]
|
|
158
171
|
(interactive first run also authenticates and optionally registers repos)
|
|
159
172
|
relay-flow task auth [task-plugin options]
|
|
160
|
-
relay-flow serve [--recover] [--debug] [--background]
|
|
173
|
+
relay-flow serve [--recover] [--debug] [--foreground | --background]
|
|
161
174
|
relay-flow stop
|
|
175
|
+
|
|
176
|
+
Plain serve starts detached, waits for Unix-socket readiness, and then returns.
|
|
177
|
+
Use serve --foreground for a blocking process-supervisor/development mode.
|
|
178
|
+
Detached startup diagnostics are written to ~/.relay-flow/server.log (or
|
|
179
|
+
$RELAY_FLOW_HOME/server.log when a custom home is configured).
|
|
162
180
|
relay-flow report
|
|
163
181
|
relay-flow version | --version | -v
|
|
164
182
|
|
|
@@ -179,6 +197,7 @@ Run:
|
|
|
179
197
|
relay-flow run get --ticket <key> [--json]
|
|
180
198
|
relay-flow run restart --ticket <key>
|
|
181
199
|
relay-flow run cancel --ticket <key>
|
|
200
|
+
relay-flow run backfill-owner --repo <name> --ticket <key> --workflow <name>
|
|
182
201
|
|
|
183
202
|
Use relay-flow <command> --help for command-specific details.
|
|
184
203
|
|
|
@@ -247,9 +266,12 @@ func printScopedHelp(args []string, w io.Writer) int {
|
|
|
247
266
|
fmt.Fprintln(w, " --force Update plugin selections while preserving existing durable state.")
|
|
248
267
|
fmt.Fprintln(w, "Example: relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode")
|
|
249
268
|
case "serve":
|
|
250
|
-
fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--background]")
|
|
251
|
-
fmt.Fprintln(w, "\nStart
|
|
252
|
-
fmt.Fprintln(w, "
|
|
269
|
+
fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--foreground | --background]")
|
|
270
|
+
fmt.Fprintln(w, "\nStart relay-flow detached and wait for the Unix socket to become ready.")
|
|
271
|
+
fmt.Fprintln(w, " --foreground Run the server in the current process until stop or signal.")
|
|
272
|
+
fmt.Fprintln(w, " --background Compatibility spelling for the default detached mode.")
|
|
273
|
+
fmt.Fprintln(w, "The detached server writes diagnostics to ~/.relay-flow/server.log (or RELAY_FLOW_HOME/server.log).")
|
|
274
|
+
fmt.Fprintln(w, "Examples: rf serve; relay-flow serve --foreground")
|
|
253
275
|
case "stop":
|
|
254
276
|
fmt.Fprintln(w, "Usage: relay-flow stop")
|
|
255
277
|
fmt.Fprintln(w, "\nStop the running relay-flow server.")
|
|
@@ -384,6 +406,9 @@ func printScopedHelp(args []string, w io.Writer) int {
|
|
|
384
406
|
fmt.Fprintln(w, " cancel Permanently cancel the current execution attempt.")
|
|
385
407
|
fmt.Fprintln(w, " Usage: relay-flow run cancel --ticket <key> [--reason <text>]")
|
|
386
408
|
fmt.Fprintln(w, " Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
|
|
409
|
+
fmt.Fprintln(w, " backfill-owner Explicitly add provenance to a legacy workflow claim.")
|
|
410
|
+
fmt.Fprintln(w, " Usage: relay-flow run backfill-owner --repo <name> --ticket <key> --workflow <name>")
|
|
411
|
+
fmt.Fprintln(w, " Example: relay-flow run backfill-owner --repo payments --ticket PAY-101 --workflow basicFlow")
|
|
387
412
|
fmt.Fprintln(w, "Use relay-flow run <subcommand> --help for flag details.")
|
|
388
413
|
case "run list":
|
|
389
414
|
fmt.Fprintln(w, "Usage: relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json] [--no-color]")
|
|
@@ -418,6 +443,15 @@ func printScopedHelp(args []string, w io.Writer) int {
|
|
|
418
443
|
fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
|
|
419
444
|
fmt.Fprintln(w, " --reason <text> Optional cancellation reason recorded with the request.")
|
|
420
445
|
fmt.Fprintln(w, "Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
|
|
446
|
+
case "run backfill-owner":
|
|
447
|
+
fmt.Fprintln(w, "Usage: relay-flow run backfill-owner --repo <name> --ticket <key> --workflow <name>")
|
|
448
|
+
fmt.Fprintln(w, "\nExplicitly add adapter-owned provenance to a legacy workflow claim.")
|
|
449
|
+
fmt.Fprintln(w, "This never removes wf: labels, comments, or history and is not automatic routing.")
|
|
450
|
+
fmt.Fprintln(w, "Flags:")
|
|
451
|
+
fmt.Fprintln(w, " --repo <name> Registered repository containing the claim (required).")
|
|
452
|
+
fmt.Fprintln(w, " --ticket <key> Task ticket key (required).")
|
|
453
|
+
fmt.Fprintln(w, " --workflow <name> Claimed workflow name (required).")
|
|
454
|
+
fmt.Fprintln(w, "Example: relay-flow run backfill-owner --repo payments --ticket PAY-101 --workflow basicFlow")
|
|
421
455
|
default:
|
|
422
456
|
fmt.Fprintf(os.Stderr, "unknown help target: %s\n", name)
|
|
423
457
|
return exitUsage
|
|
@@ -630,6 +664,10 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
630
664
|
fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
|
|
631
665
|
return exitFail
|
|
632
666
|
}
|
|
667
|
+
if names[0] == "jira" {
|
|
668
|
+
harnessDefaults["initial"] = harness.JiraInitialPrompt
|
|
669
|
+
harnessDefaults["feedback"] = harness.JiraFeedbackPrompt
|
|
670
|
+
}
|
|
633
671
|
if !*force || !configExists {
|
|
634
672
|
cfg.HarnessConfig = harnessDefaults
|
|
635
673
|
} else {
|
|
@@ -689,7 +727,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
689
727
|
fmt.Fprintln(os.Stderr, "init: "+err.Error())
|
|
690
728
|
return exitFail
|
|
691
729
|
}
|
|
692
|
-
fmt.Println("Repository setup complete. The server is running; stop it with `
|
|
730
|
+
fmt.Println("Repository setup complete. The server is running; stop it with `rf stop`.")
|
|
693
731
|
}
|
|
694
732
|
} else if err := config.SaveMachine(p.Config, cfg); err != nil {
|
|
695
733
|
fmt.Fprintln(os.Stderr, err)
|
|
@@ -821,11 +859,17 @@ func cmdServe(p paths.Paths, args []string) int {
|
|
|
821
859
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
|
822
860
|
recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
|
|
823
861
|
debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
|
|
824
|
-
|
|
862
|
+
foreground := fs.Bool("foreground", false, "run the server in the current process until stop or signal")
|
|
863
|
+
background := fs.Bool("background", false, "compatibility spelling for the default detached mode")
|
|
825
864
|
if err := fs.Parse(args); err != nil {
|
|
826
865
|
return exitUsage
|
|
827
866
|
}
|
|
828
|
-
|
|
867
|
+
child := os.Getenv(serveChildEnvironment) == "1"
|
|
868
|
+
if *foreground && *background {
|
|
869
|
+
fmt.Fprintln(os.Stderr, "serve: --foreground and --background cannot be used together")
|
|
870
|
+
return exitUsage
|
|
871
|
+
}
|
|
872
|
+
if !*foreground && !child {
|
|
829
873
|
if err := startBackgroundServe(p, *recover, *debug); err != nil {
|
|
830
874
|
fmt.Fprintln(os.Stderr, err)
|
|
831
875
|
return exitFail
|
|
@@ -833,6 +877,12 @@ func cmdServe(p paths.Paths, args []string) int {
|
|
|
833
877
|
fmt.Println("Relay-flow server started")
|
|
834
878
|
return exitOK
|
|
835
879
|
}
|
|
880
|
+
if runningAsMain {
|
|
881
|
+
if err := anchorForegroundServe(p.Root); err != nil {
|
|
882
|
+
fmt.Fprintln(os.Stderr, err)
|
|
883
|
+
return exitFail
|
|
884
|
+
}
|
|
885
|
+
}
|
|
836
886
|
logCloser, err := logging.Setup(p.ServerLog, logging.Options{
|
|
837
887
|
Debug: *debug,
|
|
838
888
|
Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
|
|
@@ -858,6 +908,9 @@ var (
|
|
|
858
908
|
)
|
|
859
909
|
|
|
860
910
|
func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
911
|
+
if err := ensureStableServeDirectory(p.Root); err != nil {
|
|
912
|
+
return fmt.Errorf("serve --background: prepare working directory: %w", err)
|
|
913
|
+
}
|
|
861
914
|
client := server.NewClient(p.Socket)
|
|
862
915
|
if serverResponding(client, 200*time.Millisecond) {
|
|
863
916
|
return fmt.Errorf("serve --background: server is already running")
|
|
@@ -869,36 +922,57 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
869
922
|
}
|
|
870
923
|
|
|
871
924
|
var (
|
|
872
|
-
cmd
|
|
873
|
-
wait
|
|
874
|
-
|
|
875
|
-
|
|
925
|
+
cmd *exec.Cmd
|
|
926
|
+
wait chan error
|
|
927
|
+
testServeCancel chan struct{}
|
|
928
|
+
observedOwner = lockHeld
|
|
929
|
+
devNull *os.File
|
|
876
930
|
)
|
|
877
931
|
if !lockHeld {
|
|
878
|
-
executable, resolveErr := os.Executable()
|
|
879
|
-
if resolveErr != nil {
|
|
880
|
-
return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
|
|
881
|
-
}
|
|
882
|
-
childArgs := []string{"serve"}
|
|
883
|
-
if recover {
|
|
884
|
-
childArgs = append(childArgs, "--recover")
|
|
885
|
-
}
|
|
886
|
-
if debug {
|
|
887
|
-
childArgs = append(childArgs, "--debug")
|
|
888
|
-
}
|
|
889
|
-
devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
|
|
890
|
-
if err != nil {
|
|
891
|
-
return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
|
|
892
|
-
}
|
|
893
|
-
defer devNull.Close()
|
|
894
|
-
cmd = exec.Command(executable, childArgs...)
|
|
895
|
-
cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
|
|
896
|
-
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
897
|
-
if err := cmd.Start(); err != nil {
|
|
898
|
-
return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
|
|
899
|
-
}
|
|
900
932
|
wait = make(chan error, 1)
|
|
901
|
-
|
|
933
|
+
if !runningAsMain {
|
|
934
|
+
// The run(args, stdin) seam is used by in-process tests. A Go test
|
|
935
|
+
// executable is not the relay-flow CLI entrypoint, so spawning
|
|
936
|
+
// os.Executable here would recursively run the test suite. Keep the
|
|
937
|
+
// production path detached while using the same serveRoot path in
|
|
938
|
+
// that seam.
|
|
939
|
+
serveCtx := &inProcessServeContext{Context: context.Background(), done: make(chan struct{})}
|
|
940
|
+
testServeCancel = serveCtx.done
|
|
941
|
+
go func() {
|
|
942
|
+
logCloser, logErr := logging.Setup(p.ServerLog, logging.Options{
|
|
943
|
+
Debug: debug,
|
|
944
|
+
Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
|
|
945
|
+
})
|
|
946
|
+
if logErr != nil {
|
|
947
|
+
wait <- logErr
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
defer logCloser.Close()
|
|
951
|
+
wait <- serveRoot(serveCtx, p, recover)
|
|
952
|
+
}()
|
|
953
|
+
} else {
|
|
954
|
+
executable, resolveErr := os.Executable()
|
|
955
|
+
if resolveErr != nil {
|
|
956
|
+
return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
|
|
957
|
+
}
|
|
958
|
+
childArgs := serveChildArgs(recover, debug)
|
|
959
|
+
devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
|
|
960
|
+
if err != nil {
|
|
961
|
+
return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
|
|
962
|
+
}
|
|
963
|
+
defer devNull.Close()
|
|
964
|
+
cmd = exec.Command(executable, childArgs...)
|
|
965
|
+
cmd.Dir = p.Root
|
|
966
|
+
childEnvironment := setEnvironment(os.Environ(), serveChildEnvironment, "1")
|
|
967
|
+
childEnvironment = setEnvironment(childEnvironment, "PWD", p.Root)
|
|
968
|
+
cmd.Env = setEnvironment(childEnvironment, "RELAY_FLOW_HOME", p.Root)
|
|
969
|
+
cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
|
|
970
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
971
|
+
if err := cmd.Start(); err != nil {
|
|
972
|
+
return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
|
|
973
|
+
}
|
|
974
|
+
go func() { wait <- cmd.Wait() }()
|
|
975
|
+
}
|
|
902
976
|
}
|
|
903
977
|
|
|
904
978
|
ticker := time.NewTicker(backgroundServePollInterval)
|
|
@@ -908,6 +982,10 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
908
982
|
return nil
|
|
909
983
|
}
|
|
910
984
|
if !time.Now().Before(deadline) {
|
|
985
|
+
if testServeCancel != nil {
|
|
986
|
+
close(testServeCancel)
|
|
987
|
+
testServeCancel = nil
|
|
988
|
+
}
|
|
911
989
|
if cmd != nil && wait != nil {
|
|
912
990
|
_ = cmd.Process.Kill()
|
|
913
991
|
<-wait
|
|
@@ -925,6 +1003,7 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
925
1003
|
}
|
|
926
1004
|
select {
|
|
927
1005
|
case childErr := <-wait:
|
|
1006
|
+
testServeCancel = nil
|
|
928
1007
|
held, lockErr := serverLockHeld(p.Lock)
|
|
929
1008
|
if lockErr != nil {
|
|
930
1009
|
return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
|
|
@@ -943,6 +1022,80 @@ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
|
|
|
943
1022
|
}
|
|
944
1023
|
}
|
|
945
1024
|
|
|
1025
|
+
func serveChildArgs(recover, debug bool) []string {
|
|
1026
|
+
args := []string{"serve"}
|
|
1027
|
+
if recover {
|
|
1028
|
+
args = append(args, "--recover")
|
|
1029
|
+
}
|
|
1030
|
+
if debug {
|
|
1031
|
+
args = append(args, "--debug")
|
|
1032
|
+
}
|
|
1033
|
+
return args
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
type inProcessServeContext struct {
|
|
1037
|
+
context.Context
|
|
1038
|
+
done chan struct{}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
func (c *inProcessServeContext) Done() <-chan struct{} {
|
|
1042
|
+
return c.done
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
func (c *inProcessServeContext) Err() error {
|
|
1046
|
+
select {
|
|
1047
|
+
case <-c.done:
|
|
1048
|
+
return context.Canceled
|
|
1049
|
+
default:
|
|
1050
|
+
return nil
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
func setEnvironment(environment []string, key, value string) []string {
|
|
1055
|
+
prefix := key + "="
|
|
1056
|
+
out := make([]string, 0, len(environment)+1)
|
|
1057
|
+
for _, entry := range environment {
|
|
1058
|
+
if strings.HasPrefix(entry, prefix) {
|
|
1059
|
+
continue
|
|
1060
|
+
}
|
|
1061
|
+
out = append(out, entry)
|
|
1062
|
+
}
|
|
1063
|
+
return append(out, prefix+value)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
func ensureStableServeDirectory(root string) error {
|
|
1067
|
+
if err := os.MkdirAll(root, 0o700); err != nil {
|
|
1068
|
+
return err
|
|
1069
|
+
}
|
|
1070
|
+
if err := os.Chmod(root, 0o700); err != nil {
|
|
1071
|
+
return err
|
|
1072
|
+
}
|
|
1073
|
+
info, err := os.Stat(root)
|
|
1074
|
+
if err != nil {
|
|
1075
|
+
return err
|
|
1076
|
+
}
|
|
1077
|
+
if !info.IsDir() {
|
|
1078
|
+
return fmt.Errorf("%s is not a directory", root)
|
|
1079
|
+
}
|
|
1080
|
+
return nil
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
func anchorForegroundServe(root string) error {
|
|
1084
|
+
if err := ensureStableServeDirectory(root); err != nil {
|
|
1085
|
+
return err
|
|
1086
|
+
}
|
|
1087
|
+
if err := os.Chdir(root); err != nil {
|
|
1088
|
+
return fmt.Errorf("anchor working directory at %s: %w", root, err)
|
|
1089
|
+
}
|
|
1090
|
+
if err := os.Setenv("PWD", root); err != nil {
|
|
1091
|
+
return fmt.Errorf("set working directory environment at %s: %w", root, err)
|
|
1092
|
+
}
|
|
1093
|
+
if err := os.Setenv("RELAY_FLOW_HOME", root); err != nil {
|
|
1094
|
+
return fmt.Errorf("set relay-flow home environment at %s: %w", root, err)
|
|
1095
|
+
}
|
|
1096
|
+
return nil
|
|
1097
|
+
}
|
|
1098
|
+
|
|
946
1099
|
func serverLockHeld(path string) (bool, error) {
|
|
947
1100
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
|
948
1101
|
if err != nil {
|
|
@@ -1400,6 +1553,23 @@ func cmdRun(c *server.Client, args []string) int {
|
|
|
1400
1553
|
return exitFail
|
|
1401
1554
|
}
|
|
1402
1555
|
return exitOK
|
|
1556
|
+
case "backfill-owner":
|
|
1557
|
+
fs := flag.NewFlagSet("run backfill-owner", flag.ContinueOnError)
|
|
1558
|
+
repoName := fs.String("repo", "", "registered repository name")
|
|
1559
|
+
ticket := fs.String("ticket", "", "ticket key")
|
|
1560
|
+
workflowName := fs.String("workflow", "", "workflow name")
|
|
1561
|
+
if err := fs.Parse(args[1:]); err != nil {
|
|
1562
|
+
return exitUsage
|
|
1563
|
+
}
|
|
1564
|
+
if *repoName == "" || *ticket == "" || *workflowName == "" {
|
|
1565
|
+
fmt.Fprintln(os.Stderr, "run backfill-owner: --repo, --ticket, and --workflow are required")
|
|
1566
|
+
return exitUsage
|
|
1567
|
+
}
|
|
1568
|
+
if err := c.BackfillClaimOwner(context.Background(), *repoName, *ticket, *workflowName); err != nil {
|
|
1569
|
+
fmt.Fprintln(os.Stderr, err)
|
|
1570
|
+
return exitFail
|
|
1571
|
+
}
|
|
1572
|
+
return exitOK
|
|
1403
1573
|
}
|
|
1404
1574
|
usage(os.Stderr)
|
|
1405
1575
|
return exitUsage
|
|
@@ -64,6 +64,17 @@ func TestScopedHelpCoversAllCommandLevels(t *testing.T) {
|
|
|
64
64
|
if !strings.Contains(root, "Use relay-flow <command> --help for command-specific details.") {
|
|
65
65
|
t.Fatalf("root help missing scoped-help guidance:\n%s", root)
|
|
66
66
|
}
|
|
67
|
+
for _, want := range []string{
|
|
68
|
+
"Plain serve starts detached",
|
|
69
|
+
"Unix-socket readiness",
|
|
70
|
+
"serve --foreground",
|
|
71
|
+
"blocking process-supervisor/development mode",
|
|
72
|
+
"server.log",
|
|
73
|
+
} {
|
|
74
|
+
if !strings.Contains(root, want) {
|
|
75
|
+
t.Fatalf("root help missing serve startup guidance %q:\n%s", want, root)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
func TestInitHelpDocumentsTemporalFlags(t *testing.T) {
|
|
@@ -337,7 +337,7 @@ func standaloneRepoServerHint(err error) error {
|
|
|
337
337
|
message := err.Error()
|
|
338
338
|
for _, marker := range []string{"dial unix", "connect: no such file or directory", "connection refused"} {
|
|
339
339
|
if strings.Contains(message, marker) {
|
|
340
|
-
return fmt.Errorf("%w; start the server with `
|
|
340
|
+
return fmt.Errorf("%w; start the server with `rf serve` (or use the guided init path; use `relay-flow serve --foreground` when a blocking process is required)", err)
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
return err
|
|
@@ -496,7 +496,7 @@ func TestRunOnboardingSpinner(t *testing.T) {
|
|
|
496
496
|
|
|
497
497
|
func TestStandaloneRepoServerHint(t *testing.T) {
|
|
498
498
|
err := standaloneRepoServerHint(errors.New("server call GET /repos: dial unix /tmp/server.sock: connect: no such file or directory"))
|
|
499
|
-
if err == nil || !containsAll(err.Error(), "serve
|
|
499
|
+
if err == nil || !containsAll(err.Error(), "rf serve", "guided init") {
|
|
500
500
|
t.Fatalf("server hint = %v", err)
|
|
501
501
|
}
|
|
502
502
|
original := errors.New("server bad request")
|
|
@@ -839,6 +839,18 @@ func (s *scenarioTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket)
|
|
|
839
839
|
return func(t task.Ticket) bool { return t.Key == scenarioTicket }, nil
|
|
840
840
|
}
|
|
841
841
|
|
|
842
|
+
func (s *scenarioTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
843
|
+
return func(task.Ticket) bool { return true }, nil
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
func (s *scenarioTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
847
|
+
return nil
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
func (s *scenarioTaskSystem) ClaimIfOwned(ctx context.Context, ref task.TicketRef, workflowName string, _ config.RawValues) error {
|
|
851
|
+
return s.Claim(ctx, ref, workflowName)
|
|
852
|
+
}
|
|
853
|
+
|
|
842
854
|
func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workflowName string) error {
|
|
843
855
|
s.log.add("claim:" + ref.Key + ":" + workflowName)
|
|
844
856
|
s.mu.Lock()
|
|
@@ -1200,19 +1212,23 @@ func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command,
|
|
|
1200
1212
|
h.sessions[spec.Title] = harness.Session{ID: "session-" + spec.Title, Title: spec.Title}
|
|
1201
1213
|
h.mu.Unlock()
|
|
1202
1214
|
h.log.add("harness-launched:" + spec.Title)
|
|
1215
|
+
env := map[string]string{
|
|
1216
|
+
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
1217
|
+
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
1218
|
+
"RELAY_FLOW_REPO": spec.RepoName,
|
|
1219
|
+
"RELAY_FLOW_TICKET": spec.Ticket,
|
|
1220
|
+
"RELAY_FLOW_NODE": spec.Node,
|
|
1221
|
+
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
1222
|
+
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
1223
|
+
"RELAY_FLOW_NEXT_STEPS_JSON": routesJSON(spec.NextSteps),
|
|
1224
|
+
}
|
|
1225
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
1226
|
+
env["RELAY_FLOW_AUTO_REJECT"] = fmt.Sprintf("%t", spec.AutoReject)
|
|
1227
|
+
}
|
|
1203
1228
|
return runner.Command{
|
|
1204
1229
|
Executable: "fake-harness",
|
|
1205
1230
|
Args: []string{"opaque", spec.Prompt},
|
|
1206
|
-
Env:
|
|
1207
|
-
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
1208
|
-
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
1209
|
-
"RELAY_FLOW_REPO": spec.RepoName,
|
|
1210
|
-
"RELAY_FLOW_TICKET": spec.Ticket,
|
|
1211
|
-
"RELAY_FLOW_NODE": spec.Node,
|
|
1212
|
-
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
1213
|
-
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
1214
|
-
"RELAY_FLOW_NEXT_STEPS_JSON": routesJSON(spec.NextSteps),
|
|
1215
|
-
},
|
|
1231
|
+
Env: env,
|
|
1216
1232
|
}, nil
|
|
1217
1233
|
}
|
|
1218
1234
|
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -541,6 +541,17 @@ func workflowSubmissionValidator(repoReg *repo.Registry, rnr runner.Runner, hrn
|
|
|
541
541
|
if _, err := rp.TaskSystem.CompileFilter(wf.TaskConfig); err != nil {
|
|
542
542
|
return fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
|
|
543
543
|
}
|
|
544
|
+
capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
|
|
545
|
+
if !ok {
|
|
546
|
+
return fmt.Errorf("workflow %q repo %q: task system lacks required ownership capabilities", wf.Name, repoName)
|
|
547
|
+
}
|
|
548
|
+
ownership, err := capabilities.CompileOwnershipFilter(wf.TaskConfig)
|
|
549
|
+
if err != nil {
|
|
550
|
+
return fmt.Errorf("workflow %q repo %q: compile ownership filter: %w", wf.Name, repoName, err)
|
|
551
|
+
}
|
|
552
|
+
if ownership == nil {
|
|
553
|
+
return fmt.Errorf("workflow %q repo %q: ownership compiler returned a nil matcher", wf.Name, repoName)
|
|
554
|
+
}
|
|
544
555
|
for nodeName, node := range wf.Nodes {
|
|
545
556
|
if (node.Type != workflow.NodeAgent && node.Type != workflow.NodeHITL) || node.Agent == "" {
|
|
546
557
|
continue
|
|
@@ -650,6 +661,15 @@ func (d *serveDeps) RestartRun(ctx context.Context, ticket string) (runsvc.Run,
|
|
|
650
661
|
func (d *serveDeps) CancelRun(ctx context.Context, ticket, reason string) error {
|
|
651
662
|
return d.runManager.CancelByTicket(ctx, ticket, reason)
|
|
652
663
|
}
|
|
664
|
+
func (d *serveDeps) BackfillClaimOwner(ctx context.Context, repoName, ticket, workflowName string) error {
|
|
665
|
+
if err := d.runManager.BackfillClaimOwner(ctx, repoName, ticket, workflowName); err != nil {
|
|
666
|
+
if errors.Is(err, task.ErrOwnershipMismatch) {
|
|
667
|
+
return fmt.Errorf("%w: %v", server.ErrConflict, err)
|
|
668
|
+
}
|
|
669
|
+
return err
|
|
670
|
+
}
|
|
671
|
+
return nil
|
|
672
|
+
}
|
|
653
673
|
|
|
654
674
|
func (d *serveDeps) SubmitReport(ctx context.Context, rep runsvc.ReportRequest) (runsvc.ReportAck, error) {
|
|
655
675
|
return d.engine.SubmitReport(ctx, rep)
|
|
@@ -720,15 +740,21 @@ func handleBatch(runManager *runsvc.RunManager) repo.BatchHandler {
|
|
|
720
740
|
case err != nil:
|
|
721
741
|
var amb *router.AmbiguousError
|
|
722
742
|
var inv *router.InvalidClaimError
|
|
743
|
+
var ownerMismatch *router.ClaimOwnerMismatchError
|
|
723
744
|
outcome := "error"
|
|
745
|
+
workflowName := ""
|
|
724
746
|
switch {
|
|
725
747
|
case errors.As(err, &amb):
|
|
726
748
|
outcome = "ambiguous"
|
|
727
749
|
case errors.As(err, &inv):
|
|
728
750
|
outcome = "invalid-claim"
|
|
751
|
+
workflowName = inv.Workflow
|
|
752
|
+
case errors.As(err, &ownerMismatch):
|
|
753
|
+
outcome = "claim-owner-mismatch"
|
|
754
|
+
workflowName = ownerMismatch.Workflow
|
|
729
755
|
}
|
|
730
756
|
slog.Info("route outcome",
|
|
731
|
-
"repo", rp.Name, "ticket", ticket.Key, "outcome", outcome, "error", err)
|
|
757
|
+
"repo", rp.Name, "ticket", ticket.Key, "workflow", workflowName, "outcome", outcome, "error", err)
|
|
732
758
|
continue
|
|
733
759
|
}
|
|
734
760
|
routeOutcome := "claimed"
|
|
@@ -42,21 +42,19 @@ taskConfig:
|
|
|
42
42
|
# parentStatus: In Progress
|
|
43
43
|
# taskStatus: In Progress
|
|
44
44
|
|
|
45
|
-
#
|
|
46
|
-
# relay-flow and is not changed by these templates.
|
|
45
|
+
# Jira mailbox descriptions include the canonical report through {{report}}.
|
|
47
46
|
templates:
|
|
48
47
|
mailboxDescription: |-
|
|
49
|
-
|
|
50
|
-
Workflow: {{workflow}}
|
|
51
|
-
Node: {{node}}
|
|
52
|
-
Node type: {{nodeType}}
|
|
53
|
-
Agent: {{agent}}
|
|
54
|
-
Mailbox: {{mailbox}}
|
|
55
|
-
|
|
56
|
-
Node work:
|
|
48
|
+
{{ticket}} / {{node}} — {{nodeType}} — {{agent}}
|
|
57
49
|
{{nodeDescription}}
|
|
58
50
|
|
|
59
|
-
|
|
51
|
+
Success routes:
|
|
52
|
+
{{successRoutes}}
|
|
53
|
+
|
|
54
|
+
Failure routes:
|
|
55
|
+
{{failureRoutes}}
|
|
56
|
+
|
|
57
|
+
{{report}}
|
|
60
58
|
|
|
61
59
|
summaryComment: |-
|
|
62
60
|
Summary for {{node}}
|
|
@@ -80,14 +78,19 @@ harnessPlugin: opencode
|
|
|
80
78
|
harnessConfig:
|
|
81
79
|
# OpenCode supports initial, feedback, and hitl templates.
|
|
82
80
|
initial: |-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
Read the description of your assigned Jira task:
|
|
82
|
+
acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
|
|
85
83
|
|
|
86
|
-
|
|
84
|
+
Follow the work instructions, valid routes, and report format in that description.
|
|
87
85
|
|
|
88
86
|
feedback: |-
|
|
89
|
-
|
|
87
|
+
Continue {{node}} from the latest update on your assigned Jira task:
|
|
88
|
+
acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
|
|
90
89
|
|
|
90
|
+
# A node needing parent details can request this additional instruction
|
|
91
|
+
# in its node description (it is not included by default):
|
|
92
|
+
# Read the parent Jira ticket for this step:
|
|
93
|
+
# acli jira workitem view "{{ticket}}" --fields "summary,description" --json
|
|
91
94
|
hitl: |-
|
|
92
95
|
Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.
|
|
93
96
|
|
|
@@ -135,12 +138,13 @@ repos:
|
|
|
135
138
|
# harnessPlugin: pi
|
|
136
139
|
# harnessConfig:
|
|
137
140
|
# initial: |
|
|
138
|
-
#
|
|
139
|
-
#
|
|
141
|
+
# Read the description of your assigned Jira task:
|
|
142
|
+
# acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
|
|
140
143
|
#
|
|
141
|
-
#
|
|
144
|
+
# Follow the work instructions, valid routes, and report format in that description.
|
|
142
145
|
# feedback: |
|
|
143
|
-
#
|
|
146
|
+
# Continue {{node}} from the latest update on your assigned Jira task:
|
|
147
|
+
# acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
|
|
144
148
|
#
|
|
145
149
|
# Pi accepts only initial and feedback harness templates. Pi workflow nodes
|
|
146
150
|
# may use agent: default or a native project prompt template in
|
|
@@ -56,6 +56,9 @@ nodes:
|
|
|
56
56
|
type: agent
|
|
57
57
|
agent: build
|
|
58
58
|
description: |
|
|
59
|
+
Read the parent Jira ticket for this step:
|
|
60
|
+
acli jira workitem view "{{ticket}}" --fields "summary,description" --json
|
|
61
|
+
|
|
59
62
|
Implement the parent ticket in the ticket worktree.
|
|
60
63
|
Run the relevant tests and verification commands.
|
|
61
64
|
nudgePrompt: |
|
|
@@ -80,7 +80,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
|
|
|
80
80
|
if err != nil {
|
|
81
81
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
84
|
+
specs[i].Description = custom
|
|
85
|
+
} else {
|
|
86
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
87
|
+
}
|
|
84
88
|
}
|
|
85
89
|
return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
|
|
86
90
|
}
|
|
@@ -488,26 +492,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
|
|
|
488
492
|
var b strings.Builder
|
|
489
493
|
b.WriteString(`Required report format:
|
|
490
494
|
|
|
491
|
-
|
|
492
|
-
NEXT STEP: <one valid node name>
|
|
493
|
-
|
|
494
|
-
SUMMARY:
|
|
495
|
-
COMPLETED:
|
|
496
|
-
COMMITS:
|
|
497
|
-
NOT COMPLETED:
|
|
498
|
-
ISSUES DISCOVERED:
|
|
499
|
-
VERIFICATION:
|
|
500
|
-
NOTES:
|
|
501
|
-
|
|
502
|
-
FEEDBACK:
|
|
503
|
-
REASON FOR NEXT STEP:
|
|
504
|
-
REQUIRED ACTIONS:
|
|
505
|
-
RELEVANT CONTEXT:
|
|
506
|
-
EXPECTED RESULT:
|
|
495
|
+
` + workflow.ReportFormat + `
|
|
507
496
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
|
|
497
|
+
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
|
|
511
498
|
writeRoutes := func(label string, routes []workflow.Route) {
|
|
512
499
|
if len(routes) == 0 {
|
|
513
500
|
return
|
|
@@ -535,7 +522,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
|
|
|
535
522
|
Agent: n.Agent, NodeDescription: n.Description,
|
|
536
523
|
NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
|
|
537
524
|
SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
|
|
538
|
-
Mailbox: ticketKey + ":" + name,
|
|
525
|
+
Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
|
|
539
526
|
},
|
|
540
527
|
}
|
|
541
528
|
}
|
|
@@ -569,7 +556,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
|
|
|
569
556
|
if err != nil {
|
|
570
557
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
571
558
|
}
|
|
572
|
-
|
|
559
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
560
|
+
specs[i].Description = custom
|
|
561
|
+
} else {
|
|
562
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
563
|
+
}
|
|
573
564
|
}
|
|
574
565
|
return specs, nil
|
|
575
566
|
}
|