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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. 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
+ }