relay-flow 0.0.1 → 0.2.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
package repo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// ActiveRuns reports whether any run of a repo is active.
|
|
14
|
+
type ActiveRuns interface {
|
|
15
|
+
HasActiveRepo(context.Context, string) (bool, error)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// WorkflowRefs reports whether any stored workflow references a repo.
|
|
19
|
+
type WorkflowRefs interface {
|
|
20
|
+
ReferencesRepo(string) bool
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ServiceConfig wires the repo service to the machine config, runner, task
|
|
24
|
+
// factory, and consumer query interfaces.
|
|
25
|
+
type ServiceConfig struct {
|
|
26
|
+
ConfigPath string
|
|
27
|
+
TaskPlugin string
|
|
28
|
+
Runner runner.Runner
|
|
29
|
+
Active ActiveRuns
|
|
30
|
+
Workflows WorkflowRefs
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Service struct {
|
|
34
|
+
cfgPath string
|
|
35
|
+
taskPlugin string
|
|
36
|
+
runner runner.Runner
|
|
37
|
+
active ActiveRuns
|
|
38
|
+
workflows WorkflowRefs
|
|
39
|
+
reg *Registry
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func NewService(cfg ServiceConfig) *Service {
|
|
43
|
+
return NewServiceWithRegistry(cfg, NewRegistry())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// NewServiceWithRegistry is NewService with a caller-supplied in-memory
|
|
47
|
+
// Registry, letting the composition root share one repo set between the
|
|
48
|
+
// engine, pollers, and HTTP handlers. Existing callers use NewService.
|
|
49
|
+
func NewServiceWithRegistry(cfg ServiceConfig, reg *Registry) *Service {
|
|
50
|
+
return &Service{
|
|
51
|
+
cfgPath: cfg.ConfigPath,
|
|
52
|
+
taskPlugin: cfg.TaskPlugin,
|
|
53
|
+
runner: cfg.Runner,
|
|
54
|
+
active: cfg.Active,
|
|
55
|
+
workflows: cfg.Workflows,
|
|
56
|
+
reg: reg,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Registry exposes the in-memory repo set for wiring (poller group, binding).
|
|
61
|
+
func (s *Service) Registry() *Registry {
|
|
62
|
+
return s.reg
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (s *Service) Discover(ctx context.Context) ([]runner.RepoCandidate, error) {
|
|
66
|
+
return s.runner.DiscoverRepos(ctx)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// RequiredRepoKeys delegates to the task factory's method of the same name.
|
|
70
|
+
func (s *Service) RequiredRepoKeys() []string {
|
|
71
|
+
keys, err := task.RequiredRepoKeys(s.taskPlugin)
|
|
72
|
+
if err != nil {
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
return keys
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type RegisterInput struct {
|
|
79
|
+
Name string
|
|
80
|
+
Path string
|
|
81
|
+
TaskConfig config.RawValues
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Register validates the runner repo, required task values, task-system
|
|
85
|
+
// connectivity, duplicate names, canonical paths, and duplicate canonical
|
|
86
|
+
// task scope before atomically writing machine config.
|
|
87
|
+
func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, error) {
|
|
88
|
+
if input.Name == "" {
|
|
89
|
+
return Info{}, fmt.Errorf("repo: name is required")
|
|
90
|
+
}
|
|
91
|
+
cfg, err := config.LoadMachine(s.cfgPath)
|
|
92
|
+
if err != nil {
|
|
93
|
+
return Info{}, err
|
|
94
|
+
}
|
|
95
|
+
if _, exists := cfg.Repos[input.Name]; exists {
|
|
96
|
+
return Info{}, fmt.Errorf("repo %q is already registered", input.Name)
|
|
97
|
+
}
|
|
98
|
+
canon := canonicalPath(input.Path)
|
|
99
|
+
for name, r := range cfg.Repos {
|
|
100
|
+
if canonicalPath(r.Path) == canon {
|
|
101
|
+
return Info{}, fmt.Errorf("repo path %q is already registered as %q", input.Path, name)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Required repo keys must be present before any remote validation.
|
|
105
|
+
keys, err := task.RequiredRepoKeys(s.taskPlugin)
|
|
106
|
+
if err != nil {
|
|
107
|
+
return Info{}, err
|
|
108
|
+
}
|
|
109
|
+
for _, k := range keys {
|
|
110
|
+
v, ok := input.TaskConfig[k]
|
|
111
|
+
if !ok || v == nil || v == "" {
|
|
112
|
+
return Info{}, fmt.Errorf("repo %q: required task config key %q is missing", input.Name, k)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Duplicate canonical task scope: compute this repo's scope and compare
|
|
116
|
+
// against every registered repo's scope.
|
|
117
|
+
newScope, err := task.TaskScopeKey(s.taskPlugin, cfg.TaskConfig, input.TaskConfig)
|
|
118
|
+
if err != nil {
|
|
119
|
+
return Info{}, fmt.Errorf("repo %q: %w", input.Name, err)
|
|
120
|
+
}
|
|
121
|
+
for name, r := range cfg.Repos {
|
|
122
|
+
scope, err := task.TaskScopeKey(s.taskPlugin, cfg.TaskConfig, r.TaskConfig)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return Info{}, fmt.Errorf("repo %q: derive scope of registered repo %q: %w", input.Name, name, err)
|
|
125
|
+
}
|
|
126
|
+
if scope == newScope {
|
|
127
|
+
return Info{}, fmt.Errorf("repo %q: task scope already used by repo %q", input.Name, name)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Runner validates the repo.
|
|
131
|
+
if err := s.runner.ValidateRepo(ctx, input.Name, input.Path); err != nil {
|
|
132
|
+
return Info{}, fmt.Errorf("repo %q: runner validation: %w", input.Name, err)
|
|
133
|
+
}
|
|
134
|
+
// Task-system connectivity: construct the repo-bound System.
|
|
135
|
+
sys, err := task.New(ctx, s.taskPlugin, task.RepoSpec{
|
|
136
|
+
Name: input.Name,
|
|
137
|
+
Path: input.Path,
|
|
138
|
+
RootConfig: cfg.TaskConfig,
|
|
139
|
+
RepoConfig: input.TaskConfig,
|
|
140
|
+
})
|
|
141
|
+
if err != nil {
|
|
142
|
+
return Info{}, fmt.Errorf("repo %q: task system connectivity: %w", input.Name, err)
|
|
143
|
+
}
|
|
144
|
+
// Persist atomically, then update the in-memory registry.
|
|
145
|
+
r := config.Repo{Path: input.Path, TaskConfig: input.TaskConfig}
|
|
146
|
+
if cfg.Repos == nil {
|
|
147
|
+
cfg.Repos = map[string]config.Repo{}
|
|
148
|
+
}
|
|
149
|
+
cfg.Repos[input.Name] = r
|
|
150
|
+
if err := config.SaveMachine(s.cfgPath, cfg); err != nil {
|
|
151
|
+
return Info{}, err
|
|
152
|
+
}
|
|
153
|
+
rp := &Repo{Name: input.Name, Path: input.Path, TaskConfig: input.TaskConfig, TaskSystem: sys}
|
|
154
|
+
s.reg.Replace(rp)
|
|
155
|
+
return rp.Info(), nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Remove deletes a registered repo. Removal is rejected while a stored
|
|
159
|
+
// workflow references the repo or an active run uses it.
|
|
160
|
+
func (s *Service) Remove(ctx context.Context, name string) error {
|
|
161
|
+
if s.workflows.ReferencesRepo(name) {
|
|
162
|
+
return fmt.Errorf("repo %q is referenced by a stored workflow; removal is rejected", name)
|
|
163
|
+
}
|
|
164
|
+
active, err := s.active.HasActiveRepo(ctx, name)
|
|
165
|
+
if err != nil {
|
|
166
|
+
return fmt.Errorf("check active runs for repo %q: %w", name, err)
|
|
167
|
+
}
|
|
168
|
+
if active {
|
|
169
|
+
return fmt.Errorf("repo %q has active runs; removal is rejected", name)
|
|
170
|
+
}
|
|
171
|
+
cfg, err := config.LoadMachine(s.cfgPath)
|
|
172
|
+
if err != nil {
|
|
173
|
+
return err
|
|
174
|
+
}
|
|
175
|
+
if _, ok := cfg.Repos[name]; !ok {
|
|
176
|
+
return fmt.Errorf("repo %q is not registered", name)
|
|
177
|
+
}
|
|
178
|
+
delete(cfg.Repos, name)
|
|
179
|
+
if err := config.SaveMachine(s.cfgPath, cfg); err != nil {
|
|
180
|
+
return err
|
|
181
|
+
}
|
|
182
|
+
s.reg.Remove(name)
|
|
183
|
+
return nil
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func (s *Service) Get(name string) (Info, error) {
|
|
187
|
+
rp, ok := s.reg.Get(name)
|
|
188
|
+
if ok {
|
|
189
|
+
return rp.Info(), nil
|
|
190
|
+
}
|
|
191
|
+
cfg, err := config.LoadMachine(s.cfgPath)
|
|
192
|
+
if err != nil {
|
|
193
|
+
return Info{}, err
|
|
194
|
+
}
|
|
195
|
+
r, ok := cfg.Repos[name]
|
|
196
|
+
if !ok {
|
|
197
|
+
return Info{}, fmt.Errorf("repo %q is not registered", name)
|
|
198
|
+
}
|
|
199
|
+
return Info{Name: name, Path: r.Path, TaskConfig: r.TaskConfig}, nil
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func (s *Service) List() []Info {
|
|
203
|
+
out := []Info{}
|
|
204
|
+
for _, rp := range s.reg.List() {
|
|
205
|
+
out = append(out, rp.Info())
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func canonicalPath(p string) string {
|
|
211
|
+
abs, err := filepath.Abs(p)
|
|
212
|
+
if err != nil {
|
|
213
|
+
return filepath.Clean(p)
|
|
214
|
+
}
|
|
215
|
+
return filepath.Clean(abs)
|
|
216
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
package repo_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"sync"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// 3.32-3.33: repo registration/removal per specs/workflow-repo-management
|
|
17
|
+
// "Repos are registered independently" and "Repo removal protects
|
|
18
|
+
// references".
|
|
19
|
+
|
|
20
|
+
type fakeRunnerDiscovery struct {
|
|
21
|
+
candidates []runner.RepoCandidate
|
|
22
|
+
validErr error
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (f *fakeRunnerDiscovery) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
26
|
+
return f.candidates, nil
|
|
27
|
+
}
|
|
28
|
+
func (f *fakeRunnerDiscovery) ValidateRepo(_ context.Context, _, _ string) error { return f.validErr }
|
|
29
|
+
func (f *fakeRunnerDiscovery) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
|
|
30
|
+
return runner.Environment{}, nil
|
|
31
|
+
}
|
|
32
|
+
func (f *fakeRunnerDiscovery) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
|
|
33
|
+
return runner.Terminal{}, false, nil
|
|
34
|
+
}
|
|
35
|
+
func (f *fakeRunnerDiscovery) InspectTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
|
|
36
|
+
return runner.Terminal{}, false, nil
|
|
37
|
+
}
|
|
38
|
+
func (f *fakeRunnerDiscovery) SendTerminal(context.Context, runner.Terminal, string) error {
|
|
39
|
+
return nil
|
|
40
|
+
}
|
|
41
|
+
func (f *fakeRunnerDiscovery) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
|
|
42
|
+
return runner.Terminal{}, nil
|
|
43
|
+
}
|
|
44
|
+
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
|
+
return runner.Terminal{}, nil
|
|
47
|
+
}
|
|
48
|
+
func (f *fakeRunnerDiscovery) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
|
|
49
|
+
func (f *fakeRunnerDiscovery) CleanupRun(context.Context, runner.RunSpec) error { return nil }
|
|
50
|
+
|
|
51
|
+
// fakeActiveRuns / fakeWorkflowRefs implement the consumer interfaces.
|
|
52
|
+
type fakeActiveRuns struct{ repos map[string]bool }
|
|
53
|
+
|
|
54
|
+
// fakeTaskSystem is a minimal working task.System for connectivity success.
|
|
55
|
+
type fakeTaskSystem struct{ task.System }
|
|
56
|
+
|
|
57
|
+
func (fakeTaskSystem) Poll(context.Context) ([]task.Ticket, error) { return nil, nil }
|
|
58
|
+
|
|
59
|
+
func newFakeSystem() task.System { return fakeTaskSystem{} }
|
|
60
|
+
|
|
61
|
+
func (f *fakeActiveRuns) HasActiveRepo(_ context.Context, name string) (bool, error) {
|
|
62
|
+
return f.repos[name], nil
|
|
63
|
+
}
|
|
64
|
+
func (f *fakeActiveRuns) set(name string, on bool) { f.repos[name] = on }
|
|
65
|
+
|
|
66
|
+
type fakeWorkflowRefs struct{ refs map[string]bool }
|
|
67
|
+
|
|
68
|
+
func (f *fakeWorkflowRefs) ReferencesRepo(name string) bool { return f.refs[name] }
|
|
69
|
+
func (f *fakeWorkflowRefs) set(name string, on bool) { f.refs[name] = on }
|
|
70
|
+
|
|
71
|
+
// The test task factory declares required repo keys and a canonical scope
|
|
72
|
+
// derived from root+repo config; New returns a working in-memory System so
|
|
73
|
+
// successful registration validates connectivity. Registered exactly once
|
|
74
|
+
// for the whole package (duplicate registration panics by design).
|
|
75
|
+
var registerTestFactoryOnce = sync.Once{}
|
|
76
|
+
|
|
77
|
+
func registerTestTaskFactory(t *testing.T) {
|
|
78
|
+
t.Helper()
|
|
79
|
+
registerTestFactoryOnce.Do(func() {
|
|
80
|
+
task.Register("testjira", task.Factory{
|
|
81
|
+
RequiredRepoKeys: func() []string { return []string{"project", "component"} },
|
|
82
|
+
TaskScopeKey: func(root, repoCfg config.RawValues) (string, error) {
|
|
83
|
+
proj, _ := repoCfg["project"].(string)
|
|
84
|
+
comp, _ := repoCfg["component"].(string)
|
|
85
|
+
if proj == "" || comp == "" {
|
|
86
|
+
return "", errors.New("project and component required")
|
|
87
|
+
}
|
|
88
|
+
return "site/" + proj + "/" + comp, nil
|
|
89
|
+
},
|
|
90
|
+
New: func(_ context.Context, spec task.RepoSpec) (task.System, error) {
|
|
91
|
+
if spec.Name == "" {
|
|
92
|
+
return nil, errors.New("task system unreachable: empty repo name")
|
|
93
|
+
}
|
|
94
|
+
return newFakeSystem(), nil
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type serviceFixture struct {
|
|
101
|
+
svc *repo.Service
|
|
102
|
+
cfgPath string
|
|
103
|
+
active *fakeActiveRuns
|
|
104
|
+
wfRefs *fakeWorkflowRefs
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
|
|
108
|
+
t.Helper()
|
|
109
|
+
registerTestTaskFactory(t)
|
|
110
|
+
dir := t.TempDir()
|
|
111
|
+
cfgPath := filepath.Join(dir, "config.yaml")
|
|
112
|
+
cfg := &config.Machine{
|
|
113
|
+
TaskPlugin: "testjira", RunnerPlugin: "orca", HarnessPlugin: "opencode",
|
|
114
|
+
Repos: map[string]config.Repo{},
|
|
115
|
+
}
|
|
116
|
+
if err := config.SaveMachine(cfgPath, cfg); err != nil {
|
|
117
|
+
t.Fatal(err)
|
|
118
|
+
}
|
|
119
|
+
active := &fakeActiveRuns{repos: map[string]bool{}}
|
|
120
|
+
wfRefs := &fakeWorkflowRefs{refs: map[string]bool{}}
|
|
121
|
+
svc := repo.NewService(repo.ServiceConfig{
|
|
122
|
+
ConfigPath: cfgPath,
|
|
123
|
+
TaskPlugin: "testjira",
|
|
124
|
+
Runner: rn,
|
|
125
|
+
Active: active,
|
|
126
|
+
Workflows: wfRefs,
|
|
127
|
+
})
|
|
128
|
+
return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func TestDiscoverDelegatesToRunner(t *testing.T) {
|
|
132
|
+
rn := &fakeRunnerDiscovery{candidates: []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}}
|
|
133
|
+
fx := newServiceFixture(t, rn)
|
|
134
|
+
got, err := fx.svc.Discover(context.Background())
|
|
135
|
+
if err != nil || len(got) != 1 || got[0].Name != "payments" {
|
|
136
|
+
t.Fatalf("Discover = %v, %v", got, err)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func TestRequiredRepoKeysDelegated(t *testing.T) {
|
|
141
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
142
|
+
keys := fx.svc.RequiredRepoKeys()
|
|
143
|
+
if len(keys) != 2 || keys[0] != "project" || keys[1] != "component" {
|
|
144
|
+
t.Fatalf("RequiredRepoKeys = %v, want [project component]", keys)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func TestRegisterMissingRequiredKeyFails(t *testing.T) {
|
|
149
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
150
|
+
// Missing "component".
|
|
151
|
+
_, err := fx.svc.Register(context.Background(), repo.RegisterInput{
|
|
152
|
+
Name: "payments", Path: "/srv/payments",
|
|
153
|
+
TaskConfig: config.RawValues{"project": "PAY"},
|
|
154
|
+
})
|
|
155
|
+
if err == nil {
|
|
156
|
+
t.Fatal("registration succeeded without a required repo key")
|
|
157
|
+
}
|
|
158
|
+
// Machine config unchanged.
|
|
159
|
+
cfg, _ := config.LoadMachine(fx.cfgPath)
|
|
160
|
+
if len(cfg.Repos) != 0 {
|
|
161
|
+
t.Fatal("machine config changed despite failed registration")
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func TestRegisterRejectsDuplicateName(t *testing.T) {
|
|
166
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
167
|
+
in := repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}
|
|
168
|
+
if _, err := fx.svc.Register(context.Background(), in); err != nil {
|
|
169
|
+
t.Fatalf("first Register failed: %v", err)
|
|
170
|
+
}
|
|
171
|
+
if _, err := fx.svc.Register(context.Background(), in); err == nil {
|
|
172
|
+
t.Fatal("duplicate repo name accepted")
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func TestRegisterRejectsDuplicateCanonicalPath(t *testing.T) {
|
|
177
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
178
|
+
okCfg := config.RawValues{"project": "PAY", "component": "api"}
|
|
179
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: okCfg}); err != nil {
|
|
180
|
+
t.Fatal(err)
|
|
181
|
+
}
|
|
182
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "pay2", Path: "/srv/payments/./", TaskConfig: config.RawValues{"project": "OT", "component": "x"}}); err == nil {
|
|
183
|
+
t.Fatal("duplicate canonical path accepted")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func TestRegisterRejectsDuplicateTaskScope(t *testing.T) {
|
|
188
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
189
|
+
scope := config.RawValues{"project": "PAY", "component": "api"}
|
|
190
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: scope}); err != nil {
|
|
191
|
+
t.Fatal(err)
|
|
192
|
+
}
|
|
193
|
+
// A different repo path but the same Jira site/project/component scope.
|
|
194
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "other", Path: "/srv/other", TaskConfig: scope}); err == nil {
|
|
195
|
+
t.Fatal("duplicate task-system scope accepted")
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func TestRegisterValidatesRunnerRepo(t *testing.T) {
|
|
200
|
+
rn := &fakeRunnerDiscovery{validErr: errInvalidRepo{}}
|
|
201
|
+
fx := newServiceFixture(t, rn)
|
|
202
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "bad", Path: "/nope", TaskConfig: config.RawValues{"project": "P", "component": "c"}}); err == nil {
|
|
203
|
+
t.Fatal("registration succeeded despite runner validation failure")
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func TestRegisterValidatesTaskConnectivity(t *testing.T) {
|
|
208
|
+
// The task factory's New is invoked to validate connectivity; its error
|
|
209
|
+
// must reject registration before persisting.
|
|
210
|
+
task.Register("failingtask", task.Factory{
|
|
211
|
+
RequiredRepoKeys: func() []string { return []string{"project"} },
|
|
212
|
+
TaskScopeKey: func(_, repoCfg config.RawValues) (string, error) { return "s/" + repoCfg["project"].(string), nil },
|
|
213
|
+
New: func(context.Context, task.RepoSpec) (task.System, error) {
|
|
214
|
+
return nil, errors.New("jira unreachable")
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
dir := t.TempDir()
|
|
218
|
+
cfgPath := filepath.Join(dir, "config.yaml")
|
|
219
|
+
cfg := &config.Machine{TaskPlugin: "failingtask", RunnerPlugin: "orca", HarnessPlugin: "opencode", Repos: map[string]config.Repo{}}
|
|
220
|
+
if err := config.SaveMachine(cfgPath, cfg); err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
svc := repo.NewService(repo.ServiceConfig{
|
|
224
|
+
ConfigPath: cfgPath, TaskPlugin: "failingtask",
|
|
225
|
+
Runner: &fakeRunnerDiscovery{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
|
|
226
|
+
})
|
|
227
|
+
_, err := svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY"}})
|
|
228
|
+
if err == nil {
|
|
229
|
+
t.Fatal("registration succeeded despite task connectivity failure")
|
|
230
|
+
}
|
|
231
|
+
cfg2, _ := config.LoadMachine(cfgPath)
|
|
232
|
+
if len(cfg2.Repos) != 0 {
|
|
233
|
+
t.Fatal("machine config persisted despite connectivity validation failure")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type errInvalidRepo struct{}
|
|
238
|
+
|
|
239
|
+
func (errInvalidRepo) Error() string { return "invalid repo" }
|
|
240
|
+
|
|
241
|
+
func TestRegisterAtomicallyPersists(t *testing.T) {
|
|
242
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
243
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
cfg, err := config.LoadMachine(fx.cfgPath)
|
|
247
|
+
if err != nil {
|
|
248
|
+
t.Fatal(err)
|
|
249
|
+
}
|
|
250
|
+
r, ok := cfg.Repos["payments"]
|
|
251
|
+
if !ok || r.Path != "/srv/payments" {
|
|
252
|
+
t.Fatalf("repo not persisted: %+v", cfg.Repos)
|
|
253
|
+
}
|
|
254
|
+
if r.TaskConfig["project"] != "PAY" {
|
|
255
|
+
t.Fatalf("repo taskConfig not persisted: %+v", r.TaskConfig)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
func TestRemoveStopsPollerAndRemoves(t *testing.T) {
|
|
260
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
261
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
|
|
262
|
+
t.Fatal(err)
|
|
263
|
+
}
|
|
264
|
+
if err := fx.svc.Remove(context.Background(), "payments"); err != nil {
|
|
265
|
+
t.Fatalf("Remove failed: %v", err)
|
|
266
|
+
}
|
|
267
|
+
if _, err := fx.svc.Get("payments"); err == nil {
|
|
268
|
+
t.Fatal("removed repo still returned by Get")
|
|
269
|
+
}
|
|
270
|
+
// The repo's poller is stopped: the registry no longer lists it.
|
|
271
|
+
for _, info := range fx.svc.List() {
|
|
272
|
+
if info.Name == "payments" {
|
|
273
|
+
t.Fatal("removed repo still listed (poller not stopped)")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
func TestRemoveRejectedWhileWorkflowReferences(t *testing.T) {
|
|
279
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
280
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
|
|
281
|
+
t.Fatal(err)
|
|
282
|
+
}
|
|
283
|
+
fx.wfRefs.set("payments", true)
|
|
284
|
+
if err := fx.svc.Remove(context.Background(), "payments"); err == nil {
|
|
285
|
+
t.Fatal("removal accepted while a stored workflow references the repo")
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
func TestRemoveRejectedWhileRunActive(t *testing.T) {
|
|
290
|
+
fx := newServiceFixture(t, &fakeRunnerDiscovery{})
|
|
291
|
+
if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
|
|
292
|
+
t.Fatal(err)
|
|
293
|
+
}
|
|
294
|
+
fx.active.set("payments", true)
|
|
295
|
+
if err := fx.svc.Remove(context.Background(), "payments"); err == nil {
|
|
296
|
+
t.Fatal("removal accepted while an active run uses the repo")
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Package retry provides the single shared error classification and backoff
|
|
2
|
+
// policy used by durable activities, repo pollers, and (mirrored) the
|
|
3
|
+
// TypeScript harness plugin.
|
|
4
|
+
package retry
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"errors"
|
|
9
|
+
"math"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Kind classifies a failure for retry purposes.
|
|
14
|
+
type Kind string
|
|
15
|
+
|
|
16
|
+
const (
|
|
17
|
+
// Transient failures are retried with backoff until they succeed.
|
|
18
|
+
Transient Kind = "transient"
|
|
19
|
+
// Conflict failures indicate a manual task-system change; the run blocks
|
|
20
|
+
// and retries with the shared backoff until external state becomes
|
|
21
|
+
// compatible again.
|
|
22
|
+
Conflict Kind = "conflict"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Failure is the classified form of an error.
|
|
26
|
+
type Failure struct {
|
|
27
|
+
Kind Kind `json:"kind"`
|
|
28
|
+
Message string `json:"message"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// conflictError marks err as a Conflict.
|
|
32
|
+
type conflictError struct{ err error }
|
|
33
|
+
|
|
34
|
+
func (e *conflictError) Error() string { return e.err.Error() }
|
|
35
|
+
func (e *conflictError) Unwrap() error { return e.err }
|
|
36
|
+
|
|
37
|
+
// ConflictError wraps err so Classify reports Kind=Conflict.
|
|
38
|
+
func ConflictError(err error) error {
|
|
39
|
+
if err == nil {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
return &conflictError{err: err}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Classify returns the Failure for err. nil yields a zero Failure.
|
|
46
|
+
func Classify(err error) Failure {
|
|
47
|
+
if err == nil {
|
|
48
|
+
return Failure{}
|
|
49
|
+
}
|
|
50
|
+
kind := Transient
|
|
51
|
+
var ce *conflictError
|
|
52
|
+
if errors.As(err, &ce) {
|
|
53
|
+
kind = Conflict
|
|
54
|
+
}
|
|
55
|
+
return Failure{Kind: kind, Message: err.Error()}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// BackoffPolicy computes exponential backoff delays with jitter.
|
|
59
|
+
type BackoffPolicy struct {
|
|
60
|
+
Initial time.Duration
|
|
61
|
+
Maximum time.Duration
|
|
62
|
+
Factor float64
|
|
63
|
+
Jitter float64
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// DefaultBackoffPolicy is the shared policy: 2s initial, factor 2, jitter 0.2,
|
|
67
|
+
// capped at 5 minutes.
|
|
68
|
+
var DefaultBackoffPolicy = BackoffPolicy{
|
|
69
|
+
Initial: 2 * time.Second,
|
|
70
|
+
Maximum: 5 * time.Minute,
|
|
71
|
+
Factor: 2,
|
|
72
|
+
Jitter: 0.2,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Delay returns the delay before the given attempt (0-based). random must be
|
|
76
|
+
// in [0,1) and supplies the jitter so durable callers can stay replay-safe.
|
|
77
|
+
func (p BackoffPolicy) Delay(attempt int, random float64) time.Duration {
|
|
78
|
+
if attempt < 0 {
|
|
79
|
+
attempt = 0
|
|
80
|
+
}
|
|
81
|
+
base := float64(p.Initial) * math.Pow(p.Factor, float64(attempt))
|
|
82
|
+
if base > float64(p.Maximum) {
|
|
83
|
+
base = float64(p.Maximum)
|
|
84
|
+
}
|
|
85
|
+
// Symmetric jitter in [1-Jitter, 1+Jitter].
|
|
86
|
+
j := (random*2 - 1) * p.Jitter
|
|
87
|
+
d := time.Duration(base * (1 + j))
|
|
88
|
+
if d < 0 {
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
if d > p.Maximum {
|
|
92
|
+
return p.Maximum
|
|
93
|
+
}
|
|
94
|
+
return d
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Do runs fn with the shared backoff until it succeeds, returns a Conflict,
|
|
98
|
+
// or ctx is canceled. Conflict failures are returned to the caller so the
|
|
99
|
+
// run can mark itself blocked.
|
|
100
|
+
func Do(ctx context.Context, policy BackoffPolicy, fn func() error) error {
|
|
101
|
+
attempt := 0
|
|
102
|
+
for {
|
|
103
|
+
err := fn()
|
|
104
|
+
if err == nil {
|
|
105
|
+
return nil
|
|
106
|
+
}
|
|
107
|
+
if Classify(err).Kind == Conflict {
|
|
108
|
+
return err
|
|
109
|
+
}
|
|
110
|
+
delay := policy.Delay(attempt, 0.5)
|
|
111
|
+
select {
|
|
112
|
+
case <-ctx.Done():
|
|
113
|
+
return ctx.Err()
|
|
114
|
+
case <-time.After(delay):
|
|
115
|
+
}
|
|
116
|
+
attempt++
|
|
117
|
+
}
|
|
118
|
+
}
|