relay-flow 0.2.0-alpha → 0.2.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run.go +6 -4
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
package/internal/repo/repo.go
CHANGED
|
@@ -32,12 +32,23 @@ type Repo struct {
|
|
|
32
32
|
TaskConfig config.RawValues
|
|
33
33
|
TaskSystem task.System
|
|
34
34
|
Workflows []WorkflowBinding
|
|
35
|
+
bindingsMu sync.RWMutex
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
func (r *Repo) Info() Info {
|
|
38
39
|
return Info{Name: r.Name, Path: r.Path, TaskConfig: r.TaskConfig}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Bindings returns a snapshot of the repo's currently published workflow
|
|
43
|
+
// bindings. WorkflowBinding values are immutable after publication.
|
|
44
|
+
func (r *Repo) Bindings() []WorkflowBinding {
|
|
45
|
+
r.bindingsMu.RLock()
|
|
46
|
+
bindings := make([]WorkflowBinding, len(r.Workflows))
|
|
47
|
+
copy(bindings, r.Workflows)
|
|
48
|
+
r.bindingsMu.RUnlock()
|
|
49
|
+
return bindings
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
// Registry is the in-memory repo set.
|
|
42
53
|
type Registry struct {
|
|
43
54
|
mu sync.RWMutex
|
|
@@ -113,7 +124,9 @@ func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
|
|
|
113
124
|
next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match})
|
|
114
125
|
}
|
|
115
126
|
sort.Slice(next, func(i, j int) bool { return next[i].Workflow.Name < next[j].Workflow.Name })
|
|
127
|
+
rp.bindingsMu.Lock()
|
|
116
128
|
rp.Workflows = next
|
|
129
|
+
rp.bindingsMu.Unlock()
|
|
117
130
|
}
|
|
118
131
|
return nil
|
|
119
132
|
}
|
package/internal/repo/service.go
CHANGED
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"path/filepath"
|
|
7
7
|
|
|
8
8
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
9
10
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
10
11
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
12
|
)
|
|
@@ -26,6 +27,7 @@ type ServiceConfig struct {
|
|
|
26
27
|
ConfigPath string
|
|
27
28
|
TaskPlugin string
|
|
28
29
|
Runner runner.Runner
|
|
30
|
+
Harness harness.Harness
|
|
29
31
|
Active ActiveRuns
|
|
30
32
|
Workflows WorkflowRefs
|
|
31
33
|
}
|
|
@@ -34,6 +36,7 @@ type Service struct {
|
|
|
34
36
|
cfgPath string
|
|
35
37
|
taskPlugin string
|
|
36
38
|
runner runner.Runner
|
|
39
|
+
harness harness.Harness
|
|
37
40
|
active ActiveRuns
|
|
38
41
|
workflows WorkflowRefs
|
|
39
42
|
reg *Registry
|
|
@@ -51,6 +54,7 @@ func NewServiceWithRegistry(cfg ServiceConfig, reg *Registry) *Service {
|
|
|
51
54
|
cfgPath: cfg.ConfigPath,
|
|
52
55
|
taskPlugin: cfg.TaskPlugin,
|
|
53
56
|
runner: cfg.Runner,
|
|
57
|
+
harness: cfg.Harness,
|
|
54
58
|
active: cfg.Active,
|
|
55
59
|
workflows: cfg.Workflows,
|
|
56
60
|
reg: reg,
|
|
@@ -141,6 +145,12 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
|
|
|
141
145
|
if err != nil {
|
|
142
146
|
return Info{}, fmt.Errorf("repo %q: task system connectivity: %w", input.Name, err)
|
|
143
147
|
}
|
|
148
|
+
if s.harness == nil {
|
|
149
|
+
return Info{}, fmt.Errorf("repo %q: harness is not configured", input.Name)
|
|
150
|
+
}
|
|
151
|
+
if err := s.harness.SetupRepo(ctx, input.Path); err != nil {
|
|
152
|
+
return Info{}, fmt.Errorf("repo %q: harness setup: %w", input.Name, err)
|
|
153
|
+
}
|
|
144
154
|
// Persist atomically, then update the in-memory registry.
|
|
145
155
|
r := config.Repo{Path: input.Path, TaskConfig: input.TaskConfig}
|
|
146
156
|
if cfg.Repos == nil {
|
|
@@ -8,6 +8,7 @@ import (
|
|
|
8
8
|
"testing"
|
|
9
9
|
|
|
10
10
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
11
12
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
12
13
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
13
14
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
@@ -29,10 +30,10 @@ func (f *fakeRunnerDiscovery) ValidateRepo(_ context.Context, _, _ string) error
|
|
|
29
30
|
func (f *fakeRunnerDiscovery) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
|
|
30
31
|
return runner.Environment{}, nil
|
|
31
32
|
}
|
|
32
|
-
func (f *fakeRunnerDiscovery)
|
|
33
|
-
return
|
|
33
|
+
func (f *fakeRunnerDiscovery) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
|
|
34
|
+
return nil
|
|
34
35
|
}
|
|
35
|
-
func (f *fakeRunnerDiscovery)
|
|
36
|
+
func (f *fakeRunnerDiscovery) FindTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
|
|
36
37
|
return runner.Terminal{}, false, nil
|
|
37
38
|
}
|
|
38
39
|
func (f *fakeRunnerDiscovery) SendTerminal(context.Context, runner.Terminal, string) error {
|
|
@@ -42,12 +43,32 @@ func (f *fakeRunnerDiscovery) CreateTerminal(context.Context, runner.Environment
|
|
|
42
43
|
return runner.Terminal{}, nil
|
|
43
44
|
}
|
|
44
45
|
func (f *fakeRunnerDiscovery) CloseTerminal(context.Context, runner.Terminal) error { return nil }
|
|
45
|
-
func (f *fakeRunnerDiscovery) EnsureTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
|
|
46
|
+
func (f *fakeRunnerDiscovery) EnsureTerminal(context.Context, runner.Environment, runner.Terminal, string, runner.Command) (runner.Terminal, error) {
|
|
46
47
|
return runner.Terminal{}, nil
|
|
47
48
|
}
|
|
48
49
|
func (f *fakeRunnerDiscovery) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
|
|
49
50
|
func (f *fakeRunnerDiscovery) CleanupRun(context.Context, runner.RunSpec) error { return nil }
|
|
50
51
|
|
|
52
|
+
type fakeHarness struct {
|
|
53
|
+
setupPaths []string
|
|
54
|
+
setupErr error
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (f *fakeHarness) SetupRepo(_ context.Context, path string) error {
|
|
58
|
+
f.setupPaths = append(f.setupPaths, path)
|
|
59
|
+
return f.setupErr
|
|
60
|
+
}
|
|
61
|
+
func (*fakeHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
62
|
+
func (*fakeHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
63
|
+
return harness.Session{}, false, nil
|
|
64
|
+
}
|
|
65
|
+
func (*fakeHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
|
|
66
|
+
return "", nil
|
|
67
|
+
}
|
|
68
|
+
func (*fakeHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
|
|
69
|
+
return runner.Command{}, nil
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
// fakeActiveRuns / fakeWorkflowRefs implement the consumer interfaces.
|
|
52
73
|
type fakeActiveRuns struct{ repos map[string]bool }
|
|
53
74
|
|
|
@@ -102,6 +123,7 @@ type serviceFixture struct {
|
|
|
102
123
|
cfgPath string
|
|
103
124
|
active *fakeActiveRuns
|
|
104
125
|
wfRefs *fakeWorkflowRefs
|
|
126
|
+
harness *fakeHarness
|
|
105
127
|
}
|
|
106
128
|
|
|
107
129
|
func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
|
|
@@ -118,14 +140,16 @@ func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
|
|
|
118
140
|
}
|
|
119
141
|
active := &fakeActiveRuns{repos: map[string]bool{}}
|
|
120
142
|
wfRefs := &fakeWorkflowRefs{refs: map[string]bool{}}
|
|
143
|
+
hrn := &fakeHarness{}
|
|
121
144
|
svc := repo.NewService(repo.ServiceConfig{
|
|
122
145
|
ConfigPath: cfgPath,
|
|
123
146
|
TaskPlugin: "testjira",
|
|
124
147
|
Runner: rn,
|
|
148
|
+
Harness: hrn,
|
|
125
149
|
Active: active,
|
|
126
150
|
Workflows: wfRefs,
|
|
127
151
|
})
|
|
128
|
-
return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs}
|
|
152
|
+
return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs, harness: hrn}
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
func TestDiscoverDelegatesToRunner(t *testing.T) {
|
|
@@ -222,7 +246,7 @@ func TestRegisterValidatesTaskConnectivity(t *testing.T) {
|
|
|
222
246
|
}
|
|
223
247
|
svc := repo.NewService(repo.ServiceConfig{
|
|
224
248
|
ConfigPath: cfgPath, TaskPlugin: "failingtask",
|
|
225
|
-
Runner: &fakeRunnerDiscovery{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
|
|
249
|
+
Runner: &fakeRunnerDiscovery{}, Harness: &fakeHarness{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
|
|
226
250
|
})
|
|
227
251
|
_, err := svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY"}})
|
|
228
252
|
if err == nil {
|
|
@@ -256,6 +280,31 @@ func TestRegisterAtomicallyPersists(t *testing.T) {
|
|
|
256
280
|
}
|
|
257
281
|
}
|
|
258
282
|
|
|
283
|
+
func TestRegisterSetsUpHarnessRepo(t *testing.T) {
|
|
284
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
285
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
|
|
286
|
+
t.Fatal(err)
|
|
287
|
+
}
|
|
288
|
+
if len(fx.harness.setupPaths) != 1 || fx.harness.setupPaths[0] != "/srv/payments" {
|
|
289
|
+
t.Fatalf("SetupRepo paths = %v", fx.harness.setupPaths)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func TestRegisterHarnessSetupFailureDoesNotPersist(t *testing.T) {
|
|
294
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
295
|
+
fx.harness.setupErr = errors.New("setup failed")
|
|
296
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err == nil {
|
|
297
|
+
t.Fatal("registration succeeded despite harness setup failure")
|
|
298
|
+
}
|
|
299
|
+
cfg, err := config.LoadMachine(fx.cfgPath)
|
|
300
|
+
if err != nil {
|
|
301
|
+
t.Fatal(err)
|
|
302
|
+
}
|
|
303
|
+
if len(cfg.Repos) != 0 {
|
|
304
|
+
t.Fatalf("repos persisted after setup failure: %v", cfg.Repos)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
259
308
|
func TestRemoveStopsPollerAndRemoves(t *testing.T) {
|
|
260
309
|
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
261
310
|
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
|
|
@@ -52,9 +52,10 @@ func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workf
|
|
|
52
52
|
if len(claims) > 1 {
|
|
53
53
|
return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: strings.Join(claims, ","), Repo: registered.Name}
|
|
54
54
|
}
|
|
55
|
+
bindings := registered.Bindings()
|
|
55
56
|
if len(claims) == 1 {
|
|
56
57
|
name := strings.TrimPrefix(claims[0], claimPrefix)
|
|
57
|
-
for _, b := range
|
|
58
|
+
for _, b := range bindings {
|
|
58
59
|
if b.Workflow.Name == name {
|
|
59
60
|
return b.Workflow, nil
|
|
60
61
|
}
|
|
@@ -62,7 +63,7 @@ func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workf
|
|
|
62
63
|
return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: name, Repo: registered.Name}
|
|
63
64
|
}
|
|
64
65
|
var matched []*workflow.Workflow
|
|
65
|
-
for _, b := range
|
|
66
|
+
for _, b := range bindings {
|
|
66
67
|
if b.Match != nil && b.Match(ticket) {
|
|
67
68
|
matched = append(matched, b.Workflow)
|
|
68
69
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
package router_test
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"sync/atomic"
|
|
5
8
|
"testing"
|
|
9
|
+
"time"
|
|
6
10
|
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
7
12
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
8
13
|
"github.com/rajpopat27/relay-flow/internal/router"
|
|
9
14
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
@@ -142,3 +147,85 @@ func TestMultipleMatchesAmbiguous(t *testing.T) {
|
|
|
142
147
|
t.Fatalf("ticket mutated on ambiguity: %+v", ticket)
|
|
143
148
|
}
|
|
144
149
|
}
|
|
150
|
+
|
|
151
|
+
type routingTaskSystem struct{ task.System }
|
|
152
|
+
|
|
153
|
+
func (routingTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
|
|
154
|
+
return []task.Ticket{{Key: "PAY-101"}}, nil
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func (routingTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
158
|
+
return func(task.Ticket) bool { return true }, nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestPollerRoutesWhileBindingsAreReplaced(t *testing.T) {
|
|
162
|
+
registry := repo.NewRegistry()
|
|
163
|
+
registered := &repo.Repo{Name: "payments", TaskSystem: routingTaskSystem{}}
|
|
164
|
+
registry.Replace(registered)
|
|
165
|
+
|
|
166
|
+
first, second := wf("firstFlow"), wf("secondFlow")
|
|
167
|
+
if err := registry.BindWorkflows([]*workflow.Workflow{first}); err != nil {
|
|
168
|
+
t.Fatal(err)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
var routed atomic.Int64
|
|
172
|
+
failures := make(chan error, 1)
|
|
173
|
+
poller := &repo.RepoPoller{
|
|
174
|
+
Repo: registered,
|
|
175
|
+
Interval: time.Microsecond,
|
|
176
|
+
Handle: func(_ context.Context, registered *repo.Repo, tickets []task.Ticket) {
|
|
177
|
+
for _, ticket := range tickets {
|
|
178
|
+
resolved, err := router.ResolveWorkflow(registered, ticket)
|
|
179
|
+
if err != nil {
|
|
180
|
+
select {
|
|
181
|
+
case failures <- err:
|
|
182
|
+
default:
|
|
183
|
+
}
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if resolved != first && resolved != second {
|
|
187
|
+
select {
|
|
188
|
+
case failures <- fmt.Errorf("resolved unexpected workflow %q", resolved.Name):
|
|
189
|
+
default:
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
routed.Add(1)
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
198
|
+
done := make(chan struct{})
|
|
199
|
+
go func() {
|
|
200
|
+
poller.Run(ctx)
|
|
201
|
+
close(done)
|
|
202
|
+
}()
|
|
203
|
+
|
|
204
|
+
for i := 0; i < 1_000; i++ {
|
|
205
|
+
bindings := []*workflow.Workflow{first}
|
|
206
|
+
if i%2 == 1 {
|
|
207
|
+
bindings = []*workflow.Workflow{second}
|
|
208
|
+
}
|
|
209
|
+
if err := registry.BindWorkflows(bindings); err != nil {
|
|
210
|
+
cancel()
|
|
211
|
+
<-done
|
|
212
|
+
t.Fatal(err)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
deadline := time.Now().Add(time.Second)
|
|
217
|
+
for routed.Load() == 0 && time.Now().Before(deadline) {
|
|
218
|
+
time.Sleep(time.Millisecond)
|
|
219
|
+
}
|
|
220
|
+
cancel()
|
|
221
|
+
<-done
|
|
222
|
+
|
|
223
|
+
select {
|
|
224
|
+
case err := <-failures:
|
|
225
|
+
t.Fatalf("routing during binding replacement failed: %v", err)
|
|
226
|
+
default:
|
|
227
|
+
}
|
|
228
|
+
if routed.Load() == 0 {
|
|
229
|
+
t.Fatal("poller did not route while bindings were replaced")
|
|
230
|
+
}
|
|
231
|
+
}
|
package/internal/run/manager.go
CHANGED
|
@@ -47,6 +47,19 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
|
|
|
47
47
|
break
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
existingRun := false
|
|
51
|
+
if claimed && m.Runs != nil {
|
|
52
|
+
existing, err := m.Runs.ListRuns(ctx, Filter{Repo: rp.Name, Workflow: wf.Name, Ticket: ticket.Key})
|
|
53
|
+
if err != nil {
|
|
54
|
+
return fmt.Errorf("check existing run %s: %w", id, err)
|
|
55
|
+
}
|
|
56
|
+
for _, candidate := range existing {
|
|
57
|
+
if candidate.ID == id {
|
|
58
|
+
existingRun = true
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
50
63
|
if !claimed {
|
|
51
64
|
if err := rp.TaskSystem.Claim(ctx, ticket.Ref(), wf.Name); err != nil {
|
|
52
65
|
slog.Info("ensure-run outcome",
|
|
@@ -54,7 +67,7 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
|
|
|
54
67
|
"outcome", "error", "stage", "claim", "error", err)
|
|
55
68
|
return fmt.Errorf("claim %s for workflow %s: %w", ticket.Key, wf.Name, err)
|
|
56
69
|
}
|
|
57
|
-
} else {
|
|
70
|
+
} else if !existingRun {
|
|
58
71
|
// Claimed but possibly missing its run (claim-before-run crash gap or
|
|
59
72
|
// retention cleanup): never recreate a canceled run.
|
|
60
73
|
marked, err := rp.TaskSystem.HasComment(ctx, task.Target{Parent: ticket.Ref()}, CancellationMarker(id))
|
package/internal/run/run.go
CHANGED
|
@@ -63,10 +63,12 @@ type NodeWork struct {
|
|
|
63
63
|
type CommentWork struct {
|
|
64
64
|
// RunID is the enclosing durable run; the comment activity uses it to
|
|
65
65
|
// attribute its 9.3 transition log lines (ticket/runID/node attrs).
|
|
66
|
-
RunID
|
|
67
|
-
Item
|
|
68
|
-
Body
|
|
69
|
-
|
|
66
|
+
RunID ID
|
|
67
|
+
Item task.Target
|
|
68
|
+
Body string
|
|
69
|
+
TextKind task.TextKind
|
|
70
|
+
TextData task.TextData
|
|
71
|
+
Marker string
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// RetryStatus describes the currently pending durable activity retry without
|
|
@@ -81,6 +81,8 @@ func (e *fakeExecutor) CancelRun(context.Context, run.ID, string) error { return
|
|
|
81
81
|
// fakeQueries answers run lookups.
|
|
82
82
|
type fakeQueries struct {
|
|
83
83
|
byTicket map[string]run.Run
|
|
84
|
+
list []run.Run
|
|
85
|
+
listErr error
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
func (q *fakeQueries) GetRun(_ context.Context, id run.ID) (run.Run, error) {
|
|
@@ -99,7 +101,9 @@ func (q *fakeQueries) FindRunByTicket(_ context.Context, ticket string) (run.Run
|
|
|
99
101
|
return run.Run{}, errors.New("not found")
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
func (q *fakeQueries) ListRuns(context.Context, run.Filter) ([]run.Run, error) {
|
|
104
|
+
func (q *fakeQueries) ListRuns(context.Context, run.Filter) ([]run.Run, error) {
|
|
105
|
+
return append([]run.Run(nil), q.list...), q.listErr
|
|
106
|
+
}
|
|
103
107
|
func (q *fakeQueries) HasActiveWorkflow(context.Context, string) (bool, error) { return false, nil }
|
|
104
108
|
func (q *fakeQueries) HasActiveRepo(context.Context, string) (bool, error) { return false, nil }
|
|
105
109
|
|
|
@@ -171,6 +175,22 @@ func TestClaimedTicketSkipsClaiming(t *testing.T) {
|
|
|
171
175
|
}
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
func TestExistingRunSkipsJiraCalls(t *testing.T) {
|
|
179
|
+
log := newEventLog()
|
|
180
|
+
sys := &recordingSystem{log: log, hasCancel: true}
|
|
181
|
+
id := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
182
|
+
exec := &fakeExecutor{log: log}
|
|
183
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{list: []run.Run{{ID: id}}}}
|
|
184
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
|
|
185
|
+
|
|
186
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
if sys.hasCommentN != 0 || len(log.all()) != 1 || len(exec.ensures) != 1 {
|
|
190
|
+
t.Fatalf("existing run caused external work: comments=%d events=%v ensures=%d", sys.hasCommentN, log.all(), len(exec.ensures))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
174
194
|
func TestClaimFailurePreventsRunCreation(t *testing.T) {
|
|
175
195
|
log := newEventLog()
|
|
176
196
|
sys := &recordingSystem{log: log, claimErr: errors.New("jira down")}
|
|
@@ -2,6 +2,7 @@ package runner_test
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"fmt"
|
|
5
6
|
"strings"
|
|
6
7
|
"testing"
|
|
7
8
|
|
|
@@ -20,9 +21,13 @@ type fakeRunner struct {
|
|
|
20
21
|
closed []string
|
|
21
22
|
cleaned []string
|
|
22
23
|
ensureN int
|
|
24
|
+
findN int
|
|
25
|
+
createN int
|
|
23
26
|
ensureEnvN int
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
var _ runner.Runner = (*fakeRunner)(nil)
|
|
30
|
+
|
|
26
31
|
func newFakeRunner() *fakeRunner {
|
|
27
32
|
return &fakeRunner{
|
|
28
33
|
envs: map[string]runner.Environment{},
|
|
@@ -57,16 +62,12 @@ func (f *fakeRunner) EnsureEnvironment(_ context.Context, s runner.RunSpec) (run
|
|
|
57
62
|
return env, nil
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
func (
|
|
61
|
-
|
|
62
|
-
term, ok := f.terminals[key]
|
|
63
|
-
if !ok || f.dead[key] {
|
|
64
|
-
// Only a live usable terminal is returned.
|
|
65
|
-
return runner.Terminal{}, false, nil
|
|
66
|
-
}
|
|
67
|
-
return term, true, nil
|
|
65
|
+
func (*fakeRunner) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
|
|
66
|
+
return nil
|
|
68
67
|
}
|
|
69
|
-
|
|
68
|
+
|
|
69
|
+
func (f *fakeRunner) FindTerminal(_ context.Context, term runner.Terminal) (runner.Terminal, bool, error) {
|
|
70
|
+
f.findN++
|
|
70
71
|
for key, current := range f.terminals {
|
|
71
72
|
if current.ID == term.ID && !f.dead[key] {
|
|
72
73
|
return current, true, nil
|
|
@@ -75,8 +76,13 @@ func (f *fakeRunner) InspectTerminal(_ context.Context, term runner.Terminal) (r
|
|
|
75
76
|
return runner.Terminal{}, false, nil
|
|
76
77
|
}
|
|
77
78
|
func (f *fakeRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
|
|
78
|
-
func (f *fakeRunner) CreateTerminal(
|
|
79
|
-
|
|
79
|
+
func (f *fakeRunner) CreateTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
|
|
80
|
+
f.createN++
|
|
81
|
+
key := env.ID + "/" + title
|
|
82
|
+
term := runner.Terminal{ID: fmt.Sprintf("term-%d-%s", f.createN, key), Title: title}
|
|
83
|
+
f.terminals[key] = term
|
|
84
|
+
delete(f.dead, key)
|
|
85
|
+
return term, nil
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
func (f *fakeRunner) CloseTerminal(_ context.Context, term runner.Terminal) error {
|
|
@@ -89,16 +95,14 @@ func (f *fakeRunner) CloseTerminal(_ context.Context, term runner.Terminal) erro
|
|
|
89
95
|
return nil
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
func (f *fakeRunner) EnsureTerminal(
|
|
98
|
+
func (f *fakeRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
|
|
93
99
|
f.ensureN++
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
if term, ok, err := f.FindTerminal(ctx, stored); err != nil {
|
|
101
|
+
return runner.Terminal{}, err
|
|
102
|
+
} else if ok {
|
|
96
103
|
return term, nil
|
|
97
104
|
}
|
|
98
|
-
|
|
99
|
-
f.terminals[key] = term
|
|
100
|
-
delete(f.dead, key)
|
|
101
|
-
return term, nil
|
|
105
|
+
return f.CreateTerminal(ctx, env, title, command)
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
func (f *fakeRunner) CloseTerminals(_ context.Context, s runner.RunSpec) error {
|
|
@@ -128,7 +132,7 @@ func TestTerminalTitleIsTicketColonNode(t *testing.T) {
|
|
|
128
132
|
if err != nil {
|
|
129
133
|
t.Fatal(err)
|
|
130
134
|
}
|
|
131
|
-
term, err := f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{Executable: "opencode"})
|
|
135
|
+
term, err := f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{Executable: "opencode"})
|
|
132
136
|
if err != nil {
|
|
133
137
|
t.Fatal(err)
|
|
134
138
|
}
|
|
@@ -148,16 +152,16 @@ func TestFindTerminalReturnsOnlyLiveUsable(t *testing.T) {
|
|
|
148
152
|
f := newFakeRunner()
|
|
149
153
|
ctx := context.Background()
|
|
150
154
|
env, _ := f.EnsureEnvironment(ctx, spec())
|
|
151
|
-
term, _ := f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
155
|
+
term, _ := f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
|
|
152
156
|
|
|
153
|
-
got, ok, err := f.FindTerminal(ctx,
|
|
157
|
+
got, ok, err := f.FindTerminal(ctx, term)
|
|
154
158
|
if err != nil || !ok || got.ID != term.ID {
|
|
155
159
|
t.Fatalf("FindTerminal = %v,%v,%v; want live terminal", got, ok, err)
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
// A stale/dead record is treated as absent.
|
|
159
163
|
f.dead[env.ID+"/PAY-101:coding"] = true
|
|
160
|
-
_, ok, err = f.FindTerminal(ctx,
|
|
164
|
+
_, ok, err = f.FindTerminal(ctx, term)
|
|
161
165
|
if err != nil {
|
|
162
166
|
t.Fatal(err)
|
|
163
167
|
}
|
|
@@ -171,7 +175,7 @@ func TestCloseTerminalsPreservesEnvironment(t *testing.T) {
|
|
|
171
175
|
ctx := context.Background()
|
|
172
176
|
sp := spec()
|
|
173
177
|
env, _ := f.EnsureEnvironment(ctx, sp)
|
|
174
|
-
_, _ = f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
178
|
+
_, _ = f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
|
|
175
179
|
|
|
176
180
|
if err := f.CloseTerminals(ctx, sp); err != nil {
|
|
177
181
|
t.Fatal(err)
|
|
@@ -189,7 +193,7 @@ func TestCleanupRunRemovesAllRunResources(t *testing.T) {
|
|
|
189
193
|
ctx := context.Background()
|
|
190
194
|
sp := spec()
|
|
191
195
|
env, _ := f.EnsureEnvironment(ctx, sp)
|
|
192
|
-
_, _ = f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
196
|
+
_, _ = f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
|
|
193
197
|
|
|
194
198
|
if err := f.CleanupRun(ctx, sp); err != nil {
|
|
195
199
|
t.Fatal(err)
|
|
@@ -213,9 +217,43 @@ func TestEnsureIdempotent(t *testing.T) {
|
|
|
213
217
|
t.Fatalf("EnsureEnvironment not idempotent: %v vs %v", env1, env2)
|
|
214
218
|
}
|
|
215
219
|
|
|
216
|
-
term1, _ := f.EnsureTerminal(ctx, env1, "PAY-101:coding", runner.Command{})
|
|
217
|
-
term2, _ := f.EnsureTerminal(ctx, env1, "PAY-101:coding", runner.Command{})
|
|
220
|
+
term1, _ := f.EnsureTerminal(ctx, env1, runner.Terminal{}, "PAY-101:coding", runner.Command{})
|
|
221
|
+
term2, _ := f.EnsureTerminal(ctx, env1, term1, "PAY-101:coding", runner.Command{})
|
|
218
222
|
if term1 != term2 {
|
|
219
223
|
t.Fatalf("EnsureTerminal not idempotent: %v vs %v", term1, term2)
|
|
220
224
|
}
|
|
225
|
+
if f.findN != 2 || f.createN != 1 {
|
|
226
|
+
t.Fatalf("EnsureTerminal calls: find=%d create=%d, want find=2 create=1", f.findN, f.createN)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func TestEnsureTerminalReplacesUnavailableStoredTerminal(t *testing.T) {
|
|
231
|
+
f := newFakeRunner()
|
|
232
|
+
ctx := context.Background()
|
|
233
|
+
env, _ := f.EnsureEnvironment(ctx, spec())
|
|
234
|
+
stored, _ := f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
|
|
235
|
+
f.dead[env.ID+"/PAY-101:coding"] = true
|
|
236
|
+
|
|
237
|
+
replacement, err := f.EnsureTerminal(ctx, env, stored, "PAY-101:coding", runner.Command{})
|
|
238
|
+
if err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
if replacement.ID == stored.ID {
|
|
242
|
+
t.Fatalf("replacement ID = stored ID %q", stored.ID)
|
|
243
|
+
}
|
|
244
|
+
if f.createN != 2 {
|
|
245
|
+
t.Fatalf("CreateTerminal calls = %d, want 2", f.createN)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func TestCreateTerminalAlwaysCreates(t *testing.T) {
|
|
250
|
+
f := newFakeRunner()
|
|
251
|
+
ctx := context.Background()
|
|
252
|
+
env, _ := f.EnsureEnvironment(ctx, spec())
|
|
253
|
+
|
|
254
|
+
first, _ := f.CreateTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
255
|
+
second, _ := f.CreateTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
256
|
+
if first.ID == second.ID {
|
|
257
|
+
t.Fatalf("CreateTerminal reused terminal %q", first.ID)
|
|
258
|
+
}
|
|
221
259
|
}
|