relay-flow 0.2.0-alpha → 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 +18 -6
- package/cmd/relay-flow/commands_test.go +519 -15
- package/cmd/relay-flow/main.go +331 -119
- package/cmd/relay-flow/scenario_test.go +209 -34
- package/cmd/relay-flow/serve.go +1 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/internal/execution/goworkflows/activities.go +65 -65
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +73 -13
- package/internal/execution/goworkflows/fakes_test.go +13 -21
- package/internal/execution/goworkflows/interpreter.go +16 -8
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +45 -21
- package/internal/execution/goworkflows/recovery_test.go +5 -5
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +5 -0
- package/internal/paths/paths.go +18 -16
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service_test.go +4 -4
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/contract_test.go +2 -0
- package/internal/task/factory.go +16 -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 +36 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +92 -61
- package/internal/task/jira/normalize.go +32 -14
- 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/transition_defaults_test.go +18 -16
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- package/internal/task/jira/acli/testdata/search_success.json +0 -1
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -2,9 +2,11 @@ package main
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"encoding/json"
|
|
5
6
|
"errors"
|
|
7
|
+
"fmt"
|
|
6
8
|
"path/filepath"
|
|
7
|
-
"
|
|
9
|
+
"reflect"
|
|
8
10
|
"strings"
|
|
9
11
|
"sync"
|
|
10
12
|
"testing"
|
|
@@ -14,18 +16,26 @@ import (
|
|
|
14
16
|
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
15
17
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
16
18
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/paths"
|
|
17
20
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
18
21
|
runsvc "github.com/rajpopat27/relay-flow/internal/run"
|
|
19
22
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
23
|
+
"github.com/rajpopat27/relay-flow/internal/server"
|
|
20
24
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
21
25
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
22
26
|
)
|
|
23
27
|
|
|
24
|
-
const
|
|
28
|
+
const (
|
|
29
|
+
scenarioTaskPlugin = "scenario-e2e-task"
|
|
30
|
+
scenarioRunnerPlugin = "scenario-e2e-runner"
|
|
31
|
+
scenarioHarnessPlugin = "scenario-e2e-harness"
|
|
32
|
+
)
|
|
25
33
|
|
|
26
34
|
var (
|
|
27
|
-
scenarioFactoryMu
|
|
28
|
-
scenarioFactorySystem
|
|
35
|
+
scenarioFactoryMu sync.Mutex
|
|
36
|
+
scenarioFactorySystem task.System
|
|
37
|
+
scenarioFactoryRunner runner.Runner
|
|
38
|
+
scenarioFactoryHarness harness.Harness
|
|
29
39
|
)
|
|
30
40
|
|
|
31
41
|
func init() {
|
|
@@ -40,9 +50,34 @@ func init() {
|
|
|
40
50
|
if scenarioFactorySystem == nil {
|
|
41
51
|
return nil, errors.New("scenario task system not configured")
|
|
42
52
|
}
|
|
53
|
+
if fake, ok := scenarioFactorySystem.(*scenarioTaskSystem); ok {
|
|
54
|
+
fake.log.add("factory:task:" + scenarioTaskPlugin)
|
|
55
|
+
}
|
|
43
56
|
return scenarioFactorySystem, nil
|
|
44
57
|
},
|
|
45
58
|
})
|
|
59
|
+
runner.Register(scenarioRunnerPlugin, func(config.RawValues) (runner.Runner, error) {
|
|
60
|
+
scenarioFactoryMu.Lock()
|
|
61
|
+
defer scenarioFactoryMu.Unlock()
|
|
62
|
+
if scenarioFactoryRunner == nil {
|
|
63
|
+
return nil, errors.New("scenario runner not configured")
|
|
64
|
+
}
|
|
65
|
+
if fake, ok := scenarioFactoryRunner.(*scenarioRunner); ok {
|
|
66
|
+
fake.log.add("factory:runner:" + scenarioRunnerPlugin)
|
|
67
|
+
}
|
|
68
|
+
return scenarioFactoryRunner, nil
|
|
69
|
+
})
|
|
70
|
+
harness.Register(scenarioHarnessPlugin, func(config.RawValues) (harness.Harness, error) {
|
|
71
|
+
scenarioFactoryMu.Lock()
|
|
72
|
+
defer scenarioFactoryMu.Unlock()
|
|
73
|
+
if scenarioFactoryHarness == nil {
|
|
74
|
+
return nil, errors.New("scenario harness not configured")
|
|
75
|
+
}
|
|
76
|
+
if fake, ok := scenarioFactoryHarness.(*scenarioHarness); ok {
|
|
77
|
+
fake.log.add("factory:harness:" + scenarioHarnessPlugin)
|
|
78
|
+
}
|
|
79
|
+
return scenarioFactoryHarness, nil
|
|
80
|
+
})
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
func setScenarioFactorySystem(system task.System) {
|
|
@@ -51,6 +86,14 @@ func setScenarioFactorySystem(system task.System) {
|
|
|
51
86
|
scenarioFactoryMu.Unlock()
|
|
52
87
|
}
|
|
53
88
|
|
|
89
|
+
func setScenarioFactoryAdapters(system task.System, rnr runner.Runner, hrn harness.Harness) {
|
|
90
|
+
scenarioFactoryMu.Lock()
|
|
91
|
+
scenarioFactorySystem = system
|
|
92
|
+
scenarioFactoryRunner = rnr
|
|
93
|
+
scenarioFactoryHarness = hrn
|
|
94
|
+
scenarioFactoryMu.Unlock()
|
|
95
|
+
}
|
|
96
|
+
|
|
54
97
|
// These scenarios exercise the same composition chain as serve:
|
|
55
98
|
// RepoPoller/handleBatch -> RunManager -> real go-workflows SQLite engine.
|
|
56
99
|
// The only replacements are the documented task, runner, and harness seams.
|
|
@@ -89,6 +132,130 @@ func TestScenarioHappyPath(t *testing.T) {
|
|
|
89
132
|
assertExactHappyEffects(t, f)
|
|
90
133
|
}
|
|
91
134
|
|
|
135
|
+
func TestCompositionRootSelectsAlternatePluginsForDurableRun(t *testing.T) {
|
|
136
|
+
log := newScenarioLog()
|
|
137
|
+
tasks := newScenarioTaskSystem(log)
|
|
138
|
+
rnr := newScenarioRunner(log)
|
|
139
|
+
hrn := newScenarioHarness(log)
|
|
140
|
+
setScenarioFactoryAdapters(tasks, rnr, hrn)
|
|
141
|
+
|
|
142
|
+
root := filepath.Join(t.TempDir(), ".relay-flow")
|
|
143
|
+
t.Setenv("RELAY_FLOW_HOME", root)
|
|
144
|
+
p, err := home()
|
|
145
|
+
if err != nil {
|
|
146
|
+
t.Fatal(err)
|
|
147
|
+
}
|
|
148
|
+
if err := paths.Ensure(p); err != nil {
|
|
149
|
+
t.Fatal(err)
|
|
150
|
+
}
|
|
151
|
+
if err := config.SaveMachine(p.Config, &config.Machine{
|
|
152
|
+
PollIntervalSeconds: 1,
|
|
153
|
+
TaskPlugin: scenarioTaskPlugin,
|
|
154
|
+
TaskConfig: config.RawValues{"provider": "alternate"},
|
|
155
|
+
RunnerPlugin: scenarioRunnerPlugin,
|
|
156
|
+
RunnerConfig: config.RawValues{"transport": "opaque"},
|
|
157
|
+
HarnessPlugin: scenarioHarnessPlugin,
|
|
158
|
+
HarnessConfig: config.RawValues{"runtime": "alternate"},
|
|
159
|
+
Repos: map[string]config.Repo{
|
|
160
|
+
scenarioRepo: {Path: scenarioRepoPath, TaskConfig: config.RawValues{"scope": "test"}},
|
|
161
|
+
},
|
|
162
|
+
}); err != nil {
|
|
163
|
+
t.Fatal(err)
|
|
164
|
+
}
|
|
165
|
+
if err := (&workflow.Store{Dir: p.Workflows}).Put(scenarioWorkflowName, scenarioWorkflowYAML); err != nil {
|
|
166
|
+
t.Fatal(err)
|
|
167
|
+
}
|
|
168
|
+
if err := goworkflows.InitDatabase(p.Database); err != nil {
|
|
169
|
+
t.Fatal(err)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
173
|
+
done := make(chan error, 1)
|
|
174
|
+
stopped := false
|
|
175
|
+
go func() { done <- serveRoot(ctx, p, false) }()
|
|
176
|
+
t.Cleanup(func() {
|
|
177
|
+
if stopped {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
cancel()
|
|
181
|
+
select {
|
|
182
|
+
case <-done:
|
|
183
|
+
case <-time.After(10 * time.Second):
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
client := server.NewClient(p.Socket)
|
|
188
|
+
waitForServer(t, client)
|
|
189
|
+
var active runsvc.Run
|
|
190
|
+
waitScenario(t, 10*time.Second, func() bool {
|
|
191
|
+
var lookupErr error
|
|
192
|
+
active, lookupErr = client.GetRunByTicket(context.Background(), scenarioTicket)
|
|
193
|
+
return lookupErr == nil && active.State == runsvc.StateWaiting && active.CurrentNode == "implement"
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
launch := hrn.launch("implement")
|
|
197
|
+
wantPrompt := "Task system: " + scenarioTaskPlugin + "\nUse the " + scenarioTaskPlugin + " tools to read the parent ticket " + scenarioTicket + "."
|
|
198
|
+
if !strings.Contains(launch.Prompt, wantPrompt) {
|
|
199
|
+
t.Fatalf("selected task system did not reach launch prompt: %q", launch.Prompt)
|
|
200
|
+
}
|
|
201
|
+
command := rnr.command(scenarioTicket + ":implement")
|
|
202
|
+
wantArgs := []string{"opaque", launch.Prompt}
|
|
203
|
+
if command.Executable != "fake-harness" || !reflect.DeepEqual(command.Args, wantArgs) {
|
|
204
|
+
t.Fatalf("runner command = %#v, want opaque harness command args %#v", command, wantArgs)
|
|
205
|
+
}
|
|
206
|
+
for _, event := range []string{
|
|
207
|
+
"factory:task:" + scenarioTaskPlugin,
|
|
208
|
+
"factory:runner:" + scenarioRunnerPlugin,
|
|
209
|
+
"factory:harness:" + scenarioHarnessPlugin,
|
|
210
|
+
"task-config-validated",
|
|
211
|
+
"runner-repo-validated",
|
|
212
|
+
"harness-agent-validated:implementer",
|
|
213
|
+
"harness-launched:" + scenarioTicket + ":implement",
|
|
214
|
+
"terminal-created:" + scenarioTicket + ":implement",
|
|
215
|
+
} {
|
|
216
|
+
if log.countPrefix(event) == 0 {
|
|
217
|
+
t.Fatalf("composition did not call selected interface boundary %q; events=%v", event, log.all())
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for i, next := range []string{"verify", "pr-review", "end"} {
|
|
222
|
+
current, getErr := client.GetRunByTicket(context.Background(), scenarioTicket)
|
|
223
|
+
if getErr != nil {
|
|
224
|
+
t.Fatal(getErr)
|
|
225
|
+
}
|
|
226
|
+
ack, submitErr := client.SubmitReport(context.Background(), runsvc.ReportRequest{
|
|
227
|
+
RunID: current.ID, Node: current.CurrentNode,
|
|
228
|
+
ReportID: "alternate-session:message-" + fmt.Sprint(i+1),
|
|
229
|
+
Report: scenarioReport(workflow.OutcomeSuccess, next),
|
|
230
|
+
})
|
|
231
|
+
if submitErr != nil || !ack.Accepted {
|
|
232
|
+
t.Fatalf("submit durable report %s -> %s: ack=%+v err=%v", current.CurrentNode, next, ack, submitErr)
|
|
233
|
+
}
|
|
234
|
+
if next != "end" {
|
|
235
|
+
waitScenario(t, 10*time.Second, func() bool {
|
|
236
|
+
advanced, advanceErr := client.GetRunByTicket(context.Background(), scenarioTicket)
|
|
237
|
+
return advanceErr == nil && advanced.State == runsvc.StateWaiting && advanced.CurrentNode == next
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
waitScenario(t, 10*time.Second, func() bool {
|
|
242
|
+
finished, getErr := client.GetRunByTicket(context.Background(), scenarioTicket)
|
|
243
|
+
return getErr == nil && finished.State == runsvc.StateCompleted
|
|
244
|
+
})
|
|
245
|
+
if err := client.Stop(context.Background()); err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
select {
|
|
249
|
+
case err := <-done:
|
|
250
|
+
if err != nil {
|
|
251
|
+
t.Fatalf("serveRoot: %v", err)
|
|
252
|
+
}
|
|
253
|
+
case <-time.After(10 * time.Second):
|
|
254
|
+
t.Fatal("serveRoot did not stop")
|
|
255
|
+
}
|
|
256
|
+
stopped = true
|
|
257
|
+
}
|
|
258
|
+
|
|
92
259
|
func TestScenarioHITLRejectLoop(t *testing.T) {
|
|
93
260
|
f := newScenarioFixture(t)
|
|
94
261
|
f.pollOnce()
|
|
@@ -629,6 +796,8 @@ type scenarioTaskSystem struct {
|
|
|
629
796
|
completeFailures int
|
|
630
797
|
}
|
|
631
798
|
|
|
799
|
+
var _ task.System = (*scenarioTaskSystem)(nil)
|
|
800
|
+
|
|
632
801
|
func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
|
|
633
802
|
return &scenarioTaskSystem{
|
|
634
803
|
log: log, mailboxes: map[string]task.Mailbox{}, specs: map[string]task.MailboxSpec{},
|
|
@@ -660,6 +829,7 @@ func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workfl
|
|
|
660
829
|
}
|
|
661
830
|
|
|
662
831
|
func (s *scenarioTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
|
|
832
|
+
s.log.add("task-config-validated")
|
|
663
833
|
return nil
|
|
664
834
|
}
|
|
665
835
|
|
|
@@ -827,6 +997,8 @@ type scenarioRunner struct {
|
|
|
827
997
|
cleanups int
|
|
828
998
|
}
|
|
829
999
|
|
|
1000
|
+
var _ runner.Runner = (*scenarioRunner)(nil)
|
|
1001
|
+
|
|
830
1002
|
func newScenarioRunner(log *scenarioLog) *scenarioRunner {
|
|
831
1003
|
return &scenarioRunner{
|
|
832
1004
|
log: log, environments: map[string]runner.Environment{}, terminals: map[string]*scenarioTerminal{},
|
|
@@ -838,7 +1010,10 @@ func (r *scenarioRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate,
|
|
|
838
1010
|
return []runner.RepoCandidate{{Name: scenarioRepo, Path: scenarioRepoPath}}, nil
|
|
839
1011
|
}
|
|
840
1012
|
|
|
841
|
-
func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error {
|
|
1013
|
+
func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error {
|
|
1014
|
+
r.log.add("runner-repo-validated")
|
|
1015
|
+
return nil
|
|
1016
|
+
}
|
|
842
1017
|
|
|
843
1018
|
func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
|
|
844
1019
|
r.mu.Lock()
|
|
@@ -852,16 +1027,12 @@ func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpe
|
|
|
852
1027
|
return env, nil
|
|
853
1028
|
}
|
|
854
1029
|
|
|
855
|
-
func (r *scenarioRunner)
|
|
856
|
-
r.
|
|
857
|
-
|
|
858
|
-
t, ok := r.terminals[title]
|
|
859
|
-
if !ok || !t.live {
|
|
860
|
-
return runner.Terminal{}, false, nil
|
|
861
|
-
}
|
|
862
|
-
return t.terminal, true, nil
|
|
1030
|
+
func (r *scenarioRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
|
|
1031
|
+
r.log.add("workspace-status:" + status)
|
|
1032
|
+
return nil
|
|
863
1033
|
}
|
|
864
|
-
|
|
1034
|
+
|
|
1035
|
+
func (r *scenarioRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
865
1036
|
r.mu.Lock()
|
|
866
1037
|
defer r.mu.Unlock()
|
|
867
1038
|
for _, current := range r.terminals {
|
|
@@ -872,8 +1043,15 @@ func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Term
|
|
|
872
1043
|
return runner.Terminal{}, false, nil
|
|
873
1044
|
}
|
|
874
1045
|
func (r *scenarioRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
|
|
875
|
-
func (r *scenarioRunner) CreateTerminal(
|
|
876
|
-
|
|
1046
|
+
func (r *scenarioRunner) CreateTerminal(_ context.Context, _ runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
1047
|
+
r.mu.Lock()
|
|
1048
|
+
defer r.mu.Unlock()
|
|
1049
|
+
terminal := runner.Terminal{ID: "terminal-" + title, Title: title}
|
|
1050
|
+
r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
|
|
1051
|
+
r.commands[title] = append(r.commands[title], command)
|
|
1052
|
+
r.launches[title]++
|
|
1053
|
+
r.log.add("terminal-created:" + title)
|
|
1054
|
+
return terminal, nil
|
|
877
1055
|
}
|
|
878
1056
|
|
|
879
1057
|
func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
|
|
@@ -886,18 +1064,13 @@ func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Termin
|
|
|
886
1064
|
return nil
|
|
887
1065
|
}
|
|
888
1066
|
|
|
889
|
-
func (r *scenarioRunner) EnsureTerminal(
|
|
890
|
-
r.
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
return
|
|
1067
|
+
func (r *scenarioRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
|
|
1068
|
+
if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
|
|
1069
|
+
return runner.Terminal{}, err
|
|
1070
|
+
} else if ok {
|
|
1071
|
+
return terminal, nil
|
|
894
1072
|
}
|
|
895
|
-
|
|
896
|
-
r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
|
|
897
|
-
r.commands[title] = append(r.commands[title], command)
|
|
898
|
-
r.launches[title]++
|
|
899
|
-
r.log.add("terminal-created:" + title)
|
|
900
|
-
return terminal, nil
|
|
1073
|
+
return r.CreateTerminal(ctx, env, title, command)
|
|
901
1074
|
}
|
|
902
1075
|
|
|
903
1076
|
func (r *scenarioRunner) CloseTerminals(context.Context, runner.RunSpec) error {
|
|
@@ -948,11 +1121,16 @@ type scenarioHarness struct {
|
|
|
948
1121
|
launches map[string][]harness.LaunchSpec
|
|
949
1122
|
}
|
|
950
1123
|
|
|
1124
|
+
var _ harness.Harness = (*scenarioHarness)(nil)
|
|
1125
|
+
|
|
951
1126
|
func newScenarioHarness(log *scenarioLog) *scenarioHarness {
|
|
952
1127
|
return &scenarioHarness{log: log, sessions: map[string]harness.Session{}, launches: map[string][]harness.LaunchSpec{}}
|
|
953
1128
|
}
|
|
954
1129
|
|
|
955
|
-
func (h *scenarioHarness) ValidateAgent(context.Context,
|
|
1130
|
+
func (h *scenarioHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
1131
|
+
h.log.add("harness-agent-validated:" + agent)
|
|
1132
|
+
return nil
|
|
1133
|
+
}
|
|
956
1134
|
|
|
957
1135
|
func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string) (harness.Session, bool, error) {
|
|
958
1136
|
h.mu.Lock()
|
|
@@ -969,6 +1147,7 @@ func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command,
|
|
|
969
1147
|
h.log.add("harness-launched:" + spec.Title)
|
|
970
1148
|
return runner.Command{
|
|
971
1149
|
Executable: "fake-harness",
|
|
1150
|
+
Args: []string{"opaque", spec.Prompt},
|
|
972
1151
|
Env: map[string]string{
|
|
973
1152
|
"RELAY_FLOW_RUN_ID": string(spec.RunID),
|
|
974
1153
|
"RELAY_FLOW_WORKFLOW": spec.Workflow,
|
|
@@ -993,12 +1172,8 @@ func (h *scenarioHarness) launch(node string) harness.LaunchSpec {
|
|
|
993
1172
|
}
|
|
994
1173
|
|
|
995
1174
|
func routesJSON(routes []workflow.Route) string {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
parts = append(parts, route.Target)
|
|
999
|
-
}
|
|
1000
|
-
sort.Strings(parts)
|
|
1001
|
-
return "[\"" + strings.Join(parts, "\",\"") + "\"]"
|
|
1175
|
+
b, _ := json.Marshal(routes)
|
|
1176
|
+
return string(b)
|
|
1002
1177
|
}
|
|
1003
1178
|
|
|
1004
1179
|
type scenarioRepoLookup struct{ reg *repo.Registry }
|
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -204,6 +204,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
|
|
|
204
204
|
Repos: repoReg,
|
|
205
205
|
Runner: rnr,
|
|
206
206
|
Harness: hrn,
|
|
207
|
+
TaskSystem: cfg.TaskPlugin,
|
|
207
208
|
RetentionDays: cfg.CompletedRunRetentionDays,
|
|
208
209
|
Runtime: &runsvc.RuntimePolicy{
|
|
209
210
|
KeepTerminalsAlive: cfg.KeepTerminalsAlive,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copy this file and replace "payments" with one or more registered repo names.
|
|
2
|
+
# Optional fields are shown commented; uncomment only the behavior you need.
|
|
3
|
+
name: defaultStoryFlow
|
|
4
|
+
repos:
|
|
5
|
+
- payments
|
|
6
|
+
cleanupRunnerOnEnd: true
|
|
7
|
+
|
|
8
|
+
taskConfig:
|
|
9
|
+
filters:
|
|
10
|
+
parentStatuses:
|
|
11
|
+
- To Do
|
|
12
|
+
issueTypes:
|
|
13
|
+
- Story
|
|
14
|
+
# labels:
|
|
15
|
+
# - coding
|
|
16
|
+
# assignees:
|
|
17
|
+
# - owner@example.com
|
|
18
|
+
# assignee: default-node-owner@example.com
|
|
19
|
+
# project: PAY
|
|
20
|
+
# component: api
|
|
21
|
+
# transitionTo:
|
|
22
|
+
# parentStatus: In Progress
|
|
23
|
+
# taskStatus: In Progress
|
|
24
|
+
|
|
25
|
+
nodes:
|
|
26
|
+
start:
|
|
27
|
+
taskConfig:
|
|
28
|
+
transitionTo:
|
|
29
|
+
parentStatus: In Progress
|
|
30
|
+
onSuccess:
|
|
31
|
+
- target: implement
|
|
32
|
+
when: The parent story is ready for implementation
|
|
33
|
+
|
|
34
|
+
implement:
|
|
35
|
+
type: agent
|
|
36
|
+
agent: coding
|
|
37
|
+
description: Implement the parent story and verify the changes.
|
|
38
|
+
# nudgePrompt: "Finish {{node}} for {{ticket}} and choose one of: {{nextSteps}}"
|
|
39
|
+
taskConfig:
|
|
40
|
+
# assignee: developer@example.com
|
|
41
|
+
transitionTo:
|
|
42
|
+
taskStatus: In Progress
|
|
43
|
+
# parentStatus: In Progress
|
|
44
|
+
onSuccess:
|
|
45
|
+
- target: review
|
|
46
|
+
when: Implementation and verification are complete
|
|
47
|
+
onFailure:
|
|
48
|
+
- target: implement
|
|
49
|
+
when: Implementation needs another pass
|
|
50
|
+
|
|
51
|
+
review:
|
|
52
|
+
type: agent
|
|
53
|
+
agent: review
|
|
54
|
+
description: Review the implementation for correctness, regressions, and missing tests.
|
|
55
|
+
# nudgePrompt: "Complete the review for {{ticket}}. Valid routes: {{nextSteps}}"
|
|
56
|
+
taskConfig:
|
|
57
|
+
# assignee: reviewer@example.com
|
|
58
|
+
transitionTo:
|
|
59
|
+
taskStatus: In Review
|
|
60
|
+
# parentStatus: In Review
|
|
61
|
+
onSuccess:
|
|
62
|
+
- target: prReview
|
|
63
|
+
when: The changes are ready for human approval
|
|
64
|
+
onFailure:
|
|
65
|
+
- target: implement
|
|
66
|
+
when: Code changes are required
|
|
67
|
+
|
|
68
|
+
prReview:
|
|
69
|
+
type: hitl
|
|
70
|
+
agent: review
|
|
71
|
+
description: Discuss the pull request with the human and obtain approval.
|
|
72
|
+
# nudgePrompt: "Review {{ticket}} with the human. Valid routes: {{nextSteps}}"
|
|
73
|
+
taskConfig:
|
|
74
|
+
# assignee: human-reviewer@example.com
|
|
75
|
+
transitionTo:
|
|
76
|
+
taskStatus: In Review
|
|
77
|
+
# parentStatus: In Review
|
|
78
|
+
onSuccess:
|
|
79
|
+
- target: end
|
|
80
|
+
when: The human approves the review
|
|
81
|
+
onFailure:
|
|
82
|
+
- target: implement
|
|
83
|
+
when: The human requests code changes
|
|
84
|
+
|
|
85
|
+
end:
|
|
86
|
+
taskConfig:
|
|
87
|
+
transitionTo:
|
|
88
|
+
parentStatus: Done
|
|
@@ -2,7 +2,6 @@ package goworkflows
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
-
"errors"
|
|
6
5
|
"fmt"
|
|
7
6
|
"log/slog"
|
|
8
7
|
"sort"
|
|
@@ -21,10 +20,11 @@ import (
|
|
|
21
20
|
// Activities holds the replaceable dependencies shared by every durable
|
|
22
21
|
// activity. One Activities value is registered with the activity worker.
|
|
23
22
|
type Activities struct {
|
|
24
|
-
Repos
|
|
25
|
-
Runner
|
|
26
|
-
Harness
|
|
27
|
-
|
|
23
|
+
Repos *repo.Registry
|
|
24
|
+
Runner runner.Runner
|
|
25
|
+
Harness harness.Harness
|
|
26
|
+
TaskSystem string
|
|
27
|
+
Runs *RunProjection
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
func (a *Activities) taskSystem(repoName string) (task.System, error) {
|
|
@@ -97,13 +97,23 @@ func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath
|
|
|
97
97
|
return a.Runner.EnsureEnvironment(ctx, spec)
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
func (a *Activities) SetEnvironmentStatus(ctx context.Context, w run.Work, repoPath, status string) error {
|
|
101
|
+
spec := a.runSpec(w)
|
|
102
|
+
spec.RepoPath = repoPath
|
|
103
|
+
env, err := a.Runner.EnsureEnvironment(ctx, spec)
|
|
104
|
+
if err != nil {
|
|
105
|
+
return err
|
|
106
|
+
}
|
|
107
|
+
return a.Runner.SetEnvironmentStatus(ctx, env, status)
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
|
|
101
111
|
return a.Runs.loadNodeRuntime(ctx, id, node)
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
// EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
|
|
105
|
-
// path. A live terminal is rebound to the new visit; otherwise
|
|
106
|
-
//
|
|
115
|
+
// path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
|
|
116
|
+
// creates a replacement and its direct ID is persisted immediately.
|
|
107
117
|
func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
|
|
108
118
|
a.Runs.runtimeMu.Lock()
|
|
109
119
|
defer a.Runs.runtimeMu.Unlock()
|
|
@@ -124,45 +134,25 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
124
134
|
if err != nil {
|
|
125
135
|
return err
|
|
126
136
|
}
|
|
137
|
+
rs := a.runSpec(nw.Work)
|
|
138
|
+
rs.RepoPath = repoPath
|
|
139
|
+
env, err := a.Runner.EnsureEnvironment(ctx, rs)
|
|
140
|
+
if err != nil {
|
|
141
|
+
return err
|
|
142
|
+
}
|
|
143
|
+
status := runner.WorkspaceStatusInProgress
|
|
144
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
145
|
+
status = runner.WorkspaceStatusInReview
|
|
146
|
+
}
|
|
147
|
+
if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
|
|
148
|
+
return err
|
|
149
|
+
}
|
|
127
150
|
hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
|
|
128
151
|
// IDs come from the guarded current row; the activity input's prior visit
|
|
129
152
|
// is used only to decide whether a live process needs rebinding.
|
|
130
153
|
rt.TerminalID = currentRuntime.TerminalID
|
|
131
154
|
rt.SessionID = currentRuntime.SessionID
|
|
132
|
-
|
|
133
|
-
if rt.TerminalID != "" {
|
|
134
|
-
terminal := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
|
|
135
|
-
_, ok, inspectErr := a.Runner.InspectTerminal(ctx, terminal)
|
|
136
|
-
if inspectErr != nil {
|
|
137
|
-
return inspectErr
|
|
138
|
-
}
|
|
139
|
-
if ok {
|
|
140
|
-
// Same-visit retry/restart leaves the running turn untouched. A
|
|
141
|
-
// revisit sends only the new work prompt to the retained session.
|
|
142
|
-
if !revisit {
|
|
143
|
-
return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
144
|
-
rt.TerminalID, rt.SessionID, rt.SessionID)
|
|
145
|
-
}
|
|
146
|
-
prompt := followUpPrompt(nw.Mailbox.Key)
|
|
147
|
-
if spec.NudgePrompt != "" {
|
|
148
|
-
prompt += "\n\n" + spec.NudgePrompt
|
|
149
|
-
}
|
|
150
|
-
if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
|
|
151
|
-
return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
152
|
-
rt.TerminalID, rt.SessionID, rt.SessionID)
|
|
153
|
-
}
|
|
154
|
-
// Direct use failed. Close the known live terminal before replacing
|
|
155
|
-
// it so a second agent process cannot be left running.
|
|
156
|
-
if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
|
|
157
|
-
return err
|
|
158
|
-
}
|
|
159
|
-
freshSession = true
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if rt.SessionID != "" && !freshSession {
|
|
164
|
-
spec.ResumeID = rt.SessionID
|
|
165
|
-
}
|
|
155
|
+
spec.ResumeID = rt.SessionID
|
|
166
156
|
// Custom instructions belong to a node entry, not same-visit recovery.
|
|
167
157
|
if !hadRuntime || revisit {
|
|
168
158
|
spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
|
|
@@ -171,34 +161,46 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
|
|
|
171
161
|
if err != nil {
|
|
172
162
|
return err
|
|
173
163
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
env, err := a.Runner.EnsureEnvironment(ctx, rs)
|
|
164
|
+
stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
|
|
165
|
+
terminal, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
|
|
177
166
|
if err != nil {
|
|
178
167
|
return err
|
|
179
168
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
169
|
+
// Persist a newly created/replacement handle before any later external
|
|
170
|
+
// effect. Runtime session registration may update SessionID independently.
|
|
171
|
+
if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
172
|
+
terminal.ID, rt.SessionID, rt.SessionID); err != nil {
|
|
173
|
+
// The visit changed after terminal creation. Close the terminal whose
|
|
174
|
+
// binding was rejected so stale work cannot leak or report.
|
|
175
|
+
if terminal.ID != rt.TerminalID {
|
|
176
|
+
_ = a.Runner.CloseTerminal(ctx, terminal)
|
|
187
177
|
}
|
|
188
|
-
|
|
189
|
-
|
|
178
|
+
return err
|
|
179
|
+
}
|
|
180
|
+
if terminal.ID != rt.TerminalID || !revisit {
|
|
181
|
+
// A newly created terminal receives its prompt in the launch command;
|
|
182
|
+
// same-visit reuse leaves the running turn untouched.
|
|
183
|
+
return nil
|
|
184
|
+
}
|
|
185
|
+
prompt := followUpPrompt(nw.Mailbox.Key)
|
|
186
|
+
if spec.NudgePrompt != "" {
|
|
187
|
+
prompt += "\n\n" + spec.NudgePrompt
|
|
190
188
|
}
|
|
191
|
-
if
|
|
192
|
-
|
|
189
|
+
if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
|
|
190
|
+
return nil
|
|
191
|
+
}
|
|
192
|
+
// Direct use failed. Close the known live terminal before replacing it so
|
|
193
|
+
// a second agent process cannot be left running.
|
|
194
|
+
if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
|
|
195
|
+
return err
|
|
193
196
|
}
|
|
197
|
+
replacement, err := a.Runner.EnsureTerminal(ctx, env, terminal, spec.Title, cmd)
|
|
194
198
|
if err != nil {
|
|
195
199
|
return err
|
|
196
200
|
}
|
|
197
201
|
if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// binding was rejected so stale work cannot leak or report.
|
|
201
|
-
_ = a.Runner.CloseTerminal(ctx, terminal)
|
|
202
|
+
replacement.ID, rt.SessionID, rt.SessionID); err != nil {
|
|
203
|
+
_ = a.Runner.CloseTerminal(ctx, replacement)
|
|
202
204
|
return err
|
|
203
205
|
}
|
|
204
206
|
return nil
|
|
@@ -393,15 +395,13 @@ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, statu
|
|
|
393
395
|
// description, and every legal route with its when explanation.
|
|
394
396
|
func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
|
|
395
397
|
var b strings.Builder
|
|
396
|
-
fmt.Fprintf(&b, "Parent
|
|
398
|
+
fmt.Fprintf(&b, "Parent ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
|
|
397
399
|
ticketKey, name, n.Type, n.Agent, n.Description)
|
|
398
400
|
if n.Type == workflow.NodeHITL {
|
|
399
401
|
b.WriteString(`
|
|
400
402
|
|
|
401
403
|
1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
|
|
402
|
-
2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review
|
|
403
|
-
3. Present the complete report through OpenCode's Question tool with exactly two options: Approve and Reject.
|
|
404
|
-
4. If approved, output the report verbatim. If rejected, return to step 1.`)
|
|
404
|
+
2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.`)
|
|
405
405
|
}
|
|
406
406
|
b.WriteString(`
|
|
407
407
|
|
|
@@ -465,8 +465,8 @@ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
|
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
// BuildLaunchSpecPrompt points the agent to its parent and isolated mailbox.
|
|
468
|
-
func BuildLaunchSpecPrompt(ticketKey, mailboxKey string) string {
|
|
469
|
-
return fmt.Sprintf("
|
|
468
|
+
func BuildLaunchSpecPrompt(taskSystem, ticketKey, mailboxKey string) string {
|
|
469
|
+
return fmt.Sprintf("Task system: %s\nUse the %s tools to read the parent ticket %s.\n\nYour mailbox is %s. Read its description and comments for node instructions and feedback.", taskSystem, taskSystem, ticketKey, mailboxKey)
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
func followUpPrompt(mailboxKey string) string {
|