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,186 @@
1
+ package repo
2
+
3
+ import (
4
+ "context"
5
+ "sync"
6
+ "time"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/task"
9
+ )
10
+
11
+ // BatchHandler receives one polled parent batch for a repo. Routing,
12
+ // claiming, and run starts are the handler's job; pollers only fetch.
13
+ type BatchHandler func(context.Context, *Repo, []task.Ticket)
14
+
15
+ // RepoPoller polls one repo's task system on a fixed interval and hands
16
+ // each fetched parent batch to the handler. It never routes, claims, or
17
+ // compiles filters.
18
+ type RepoPoller struct {
19
+ Repo *Repo
20
+ Interval time.Duration
21
+ Handle BatchHandler
22
+
23
+ sem chan struct{} // shared concurrency cap; nil means uncapped
24
+ }
25
+
26
+ // Run polls immediately, then on every interval, until ctx is canceled.
27
+ func (p *RepoPoller) Run(ctx context.Context) {
28
+ p.poll(ctx)
29
+ ticker := time.NewTicker(p.Interval)
30
+ defer ticker.Stop()
31
+ for {
32
+ select {
33
+ case <-ctx.Done():
34
+ return
35
+ case <-ticker.C:
36
+ p.poll(ctx)
37
+ }
38
+ }
39
+ }
40
+
41
+ func (p *RepoPoller) poll(ctx context.Context) {
42
+ if p.sem != nil {
43
+ select {
44
+ case p.sem <- struct{}{}:
45
+ defer func() { <-p.sem }()
46
+ case <-ctx.Done():
47
+ return
48
+ }
49
+ }
50
+ batch, err := p.Repo.TaskSystem.Poll(ctx)
51
+ if err != nil {
52
+ return
53
+ }
54
+ p.Handle(ctx, p.Repo, batch)
55
+ }
56
+
57
+ // PollerGroup runs one lightweight timer goroutine (RepoPoller) per
58
+ // registered repo. A shared semaphore limits concurrent task-system polls
59
+ // to maxConcurrent (10 per design). Every poller uses the machine-wide
60
+ // poll interval supplied by the startup wiring.
61
+ //
62
+ // Run applies ReplaceRepos to the LIVE loop: it diffs the desired repo set
63
+ // against the running pollers on every wake, starting pollers for newly
64
+ // registered repos and canceling pollers for removed ones, so repos
65
+ // registered after serve starts are polled within one poll tick.
66
+ type PollerGroup struct {
67
+ mu sync.RWMutex
68
+ pollers []*RepoPoller
69
+ max int
70
+ handle BatchHandler
71
+ sem chan struct{}
72
+
73
+ // changed is signalled (non-blocking send) by ReplaceRepos so Run
74
+ // re-diffs immediately instead of waiting for the next tick.
75
+ changed chan struct{}
76
+
77
+ // Interval is the machine-wide poll interval (Machine.PollIntervalSeconds)
78
+ // applied to every repo poller. Set by startup wiring before ReplaceRepos.
79
+ Interval time.Duration
80
+ }
81
+
82
+ // NewPollerGroup builds a group capping concurrent polls at maxConcurrent.
83
+ func NewPollerGroup(maxConcurrent int, handle BatchHandler) *PollerGroup {
84
+ if maxConcurrent <= 0 {
85
+ maxConcurrent = 10
86
+ }
87
+ return &PollerGroup{
88
+ max: maxConcurrent,
89
+ handle: handle,
90
+ sem: make(chan struct{}, maxConcurrent),
91
+ changed: make(chan struct{}, 1),
92
+ }
93
+ }
94
+
95
+ // ReplaceRepos swaps the repo set polled by the group. The new set applies
96
+ // from the next scheduling cycle, or immediately if Run is live.
97
+ func (g *PollerGroup) ReplaceRepos(repos []*Repo) {
98
+ g.mu.Lock()
99
+ interval := g.Interval
100
+ if interval <= 0 {
101
+ interval = 15 * time.Second
102
+ }
103
+ pollers := make([]*RepoPoller, 0, len(repos))
104
+ for _, rp := range repos {
105
+ pollers = append(pollers, &RepoPoller{
106
+ Repo: rp,
107
+ Interval: interval,
108
+ Handle: g.handle,
109
+ sem: g.sem,
110
+ })
111
+ }
112
+ g.pollers = pollers
113
+ g.mu.Unlock()
114
+ select {
115
+ case g.changed <- struct{}{}:
116
+ default:
117
+ }
118
+ }
119
+
120
+ func (g *PollerGroup) snapshot() []*RepoPoller {
121
+ g.mu.RLock()
122
+ defer g.mu.RUnlock()
123
+ return append([]*RepoPoller(nil), g.pollers...)
124
+ }
125
+
126
+ // reconcile diffs the desired poller set against running pollers keyed by
127
+ // repo: starts pollers for new repos, cancels pollers for removed repos,
128
+ // and leaves already-running pollers for still-present repos untouched
129
+ // (so their interval timers are not reset by a no-op re-register).
130
+ func (g *PollerGroup) reconcile(ctx context.Context, running map[*Repo]context.CancelFunc, wg *sync.WaitGroup) {
131
+ want := make(map[*Repo]*RepoPoller, len(g.snapshot()))
132
+ for _, p := range g.snapshot() {
133
+ want[p.Repo] = p
134
+ }
135
+ for rp, cancel := range running {
136
+ if _, ok := want[rp]; !ok {
137
+ cancel()
138
+ delete(running, rp)
139
+ }
140
+ }
141
+ for rp, p := range want {
142
+ if _, ok := running[rp]; ok {
143
+ continue
144
+ }
145
+ pctx, cancel := context.WithCancel(ctx)
146
+ running[rp] = cancel
147
+ wg.Add(1)
148
+ go func(p *RepoPoller) {
149
+ defer wg.Done()
150
+ p.Run(pctx)
151
+ }(p)
152
+ }
153
+ }
154
+
155
+ // Run starts one RepoPoller goroutine per repo in the current set, keeps
156
+ // the running set in sync with ReplaceRepos, and blocks until ctx is
157
+ // canceled. On each wake (poll tick or ReplaceRepos signal) it reconciles
158
+ // the running pollers against the desired set.
159
+ func (g *PollerGroup) Run(ctx context.Context) {
160
+ interval := g.Interval
161
+ if interval <= 0 {
162
+ interval = 15 * time.Second
163
+ }
164
+ var wg sync.WaitGroup
165
+ running := map[*Repo]context.CancelFunc{}
166
+ stopAll := func() {
167
+ for _, cancel := range running {
168
+ cancel()
169
+ }
170
+ }
171
+ g.reconcile(ctx, running, &wg)
172
+ ticker := time.NewTicker(interval)
173
+ defer ticker.Stop()
174
+ for {
175
+ select {
176
+ case <-ctx.Done():
177
+ stopAll()
178
+ wg.Wait()
179
+ return
180
+ case <-g.changed:
181
+ g.reconcile(ctx, running, &wg)
182
+ case <-ticker.C:
183
+ g.reconcile(ctx, running, &wg)
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,327 @@
1
+ package repo_test
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "sync/atomic"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/repo"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ )
14
+
15
+ // 3.10: Repo Poller tests per specs/repo-workflow-routing "One Repo Poller
16
+ // fetches each repo": one poller per registered repo, all pollers use root
17
+ // pollIntervalSeconds, a semaphore caps concurrent polls at 10, pollers
18
+ // only fetch batches and call the batch handler.
19
+
20
+ // pollSystem records polls and can block to measure concurrency. batch is
21
+ // the fixed ticket set it returns.
22
+ type pollSystem struct {
23
+ task.System // unused primitives
24
+ shared *pollCounter
25
+ polls atomic.Int32
26
+ block chan struct{}
27
+ batch []task.Ticket
28
+ t *testing.T // when set, any matching/claiming call fails the test
29
+ }
30
+
31
+ // The poller must never route, claim, or compile filters — those are the
32
+ // batch handler's job (task 5.3). These overrides fail loudly if called.
33
+ func (p *pollSystem) forbidden(name string) {
34
+ if p.t != nil {
35
+ p.t.Fatalf("poller invoked %s; a poller only fetches (Poll) and hands the batch to the handler", name)
36
+ }
37
+ }
38
+ func (p *pollSystem) Claim(context.Context, task.TicketRef, string) error {
39
+ p.forbidden("Claim")
40
+ return nil
41
+ }
42
+ func (p *pollSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
43
+ p.forbidden("CompileFilter")
44
+ return nil, nil
45
+ }
46
+ func (p *pollSystem) ApplyTaskConfig(context.Context, task.Target, config.RawValues) error {
47
+ p.forbidden("ApplyTaskConfig")
48
+ return nil
49
+ }
50
+ func (p *pollSystem) CompleteMailbox(context.Context, task.Mailbox) error {
51
+ p.forbidden("CompleteMailbox")
52
+ return nil
53
+ }
54
+
55
+ type pollCounter struct {
56
+ active atomic.Int32
57
+ maxSeen atomic.Int32
58
+ }
59
+
60
+ func (p *pollSystem) Poll(ctx context.Context) ([]task.Ticket, error) {
61
+ if p.shared != nil {
62
+ cur := p.shared.active.Add(1)
63
+ for {
64
+ old := p.shared.maxSeen.Load()
65
+ if cur <= old || p.shared.maxSeen.CompareAndSwap(old, cur) {
66
+ break
67
+ }
68
+ }
69
+ defer p.shared.active.Add(-1)
70
+ }
71
+ p.polls.Add(1)
72
+ if p.block != nil {
73
+ select {
74
+ case <-p.block:
75
+ case <-ctx.Done():
76
+ }
77
+ }
78
+ return p.batch, nil
79
+ }
80
+
81
+ func makeRepo(name string, sys task.System) *repo.Repo {
82
+ return &repo.Repo{Name: name, Path: "/srv/" + name, TaskSystem: sys}
83
+ }
84
+
85
+ func TestPollerGroupRunsOnePollerPerRepo(t *testing.T) {
86
+ systems := map[string]*pollSystem{}
87
+ var repos []*repo.Repo
88
+ for _, name := range []string{"a", "b", "c"} {
89
+ s := &pollSystem{}
90
+ systems[name] = s
91
+ repos = append(repos, makeRepo(name, s))
92
+ }
93
+
94
+ var handled atomic.Int32
95
+ g := repo.NewPollerGroup(10, func(context.Context, *repo.Repo, []task.Ticket) {
96
+ handled.Add(1)
97
+ })
98
+ g.ReplaceRepos(repos)
99
+
100
+ ctx, cancel := context.WithCancel(context.Background())
101
+ defer cancel()
102
+ go g.Run(ctx)
103
+
104
+ // Wait until every repo has polled at least once.
105
+ deadline := time.Now().Add(5 * time.Second)
106
+ for time.Now().Before(deadline) {
107
+ all := true
108
+ for _, s := range systems {
109
+ if s.polls.Load() == 0 {
110
+ all = false
111
+ }
112
+ }
113
+ if all {
114
+ break
115
+ }
116
+ time.Sleep(10 * time.Millisecond)
117
+ }
118
+ for name, s := range systems {
119
+ if s.polls.Load() == 0 {
120
+ t.Fatalf("repo %s never polled; one poller per repo expected", name)
121
+ }
122
+ }
123
+ if handled.Load() == 0 {
124
+ t.Fatal("batch handler never called")
125
+ }
126
+ }
127
+
128
+ func TestPollerGroupCapsConcurrentPolls(t *testing.T) {
129
+ // More than 10 repos due at once: at most 10 polls run concurrently.
130
+ block := make(chan struct{})
131
+ shared := &pollCounter{}
132
+ var repos []*repo.Repo
133
+ for i := 0; i < 15; i++ {
134
+ s := &pollSystem{shared: shared, block: block}
135
+ repos = append(repos, makeRepo(string(rune('a'+i)), s))
136
+ }
137
+
138
+ g := repo.NewPollerGroup(10, func(context.Context, *repo.Repo, []task.Ticket) {})
139
+ g.ReplaceRepos(repos)
140
+ ctx, cancel := context.WithCancel(context.Background())
141
+ defer cancel()
142
+ go g.Run(ctx)
143
+
144
+ time.Sleep(500 * time.Millisecond)
145
+ if got := shared.maxSeen.Load(); got > 10 {
146
+ t.Fatalf("concurrent polls peaked at %d, cap is 10", got)
147
+ }
148
+ close(block)
149
+ }
150
+
151
+ func TestRepoPollerUsesConfiguredInterval(t *testing.T) {
152
+ s := &pollSystem{}
153
+ r := makeRepo("a", s)
154
+
155
+ p := &repo.RepoPoller{
156
+ Repo: r,
157
+ Interval: 50 * time.Millisecond, // mirrors root pollIntervalSeconds plumbing
158
+ Handle: func(context.Context, *repo.Repo, []task.Ticket) {},
159
+ }
160
+ ctx, cancel := context.WithCancel(context.Background())
161
+ defer cancel()
162
+ go p.Run(ctx)
163
+ time.Sleep(220 * time.Millisecond)
164
+ cancel()
165
+
166
+ if s.polls.Load() < 2 {
167
+ t.Fatalf("poller ran %d times in 220ms at 50ms interval, want several", s.polls.Load())
168
+ }
169
+ }
170
+
171
+ func TestPollerGroupPollsEveryRepoOnSharedInterval(t *testing.T) {
172
+ // One Repo Poller per repo, all using the machine-wide interval. The
173
+ // documented interval surface is RepoPoller.Interval; the startup wiring
174
+ // sets every poller's Interval from Machine.PollIntervalSeconds. Build
175
+ // them uniformly as the wiring does and assert the documented field.
176
+ systems := map[string]*pollSystem{}
177
+ var repos []*repo.Repo
178
+ for _, name := range []string{"a", "b", "c"} {
179
+ s := &pollSystem{}
180
+ systems[name] = s
181
+ repos = append(repos, makeRepo(name, s))
182
+ }
183
+
184
+ shared := 15 * time.Second // Machine.PollIntervalSeconds default
185
+ pollers := make([]*repo.RepoPoller, 0, len(repos))
186
+ for _, r := range repos {
187
+ p := &repo.RepoPoller{Repo: r, Interval: shared, Handle: func(context.Context, *repo.Repo, []task.Ticket) {}}
188
+ if p.Interval != shared {
189
+ t.Fatalf("repo %s poller interval = %v, want machine-wide %v", r.Name, p.Interval, shared)
190
+ }
191
+ pollers = append(pollers, p)
192
+ }
193
+
194
+ // The group runs one poller per repo (each repo polled).
195
+ g := repo.NewPollerGroup(10, func(context.Context, *repo.Repo, []task.Ticket) {})
196
+ g.ReplaceRepos(repos)
197
+ ctx, cancel := context.WithCancel(context.Background())
198
+ defer cancel()
199
+ go g.Run(ctx)
200
+ time.Sleep(200 * time.Millisecond)
201
+ cancel()
202
+ for name, s := range systems {
203
+ if s.polls.Load() == 0 {
204
+ t.Fatalf("repo %s never polled by the group", name)
205
+ }
206
+ }
207
+ }
208
+
209
+ func TestPollIntervalDefaultsTo15Seconds(t *testing.T) {
210
+ // pollIntervalSeconds defaults to 15 when omitted. The machine config
211
+ // owns this default; assert it here so the poller group's source value
212
+ // is pinned.
213
+ dir := t.TempDir()
214
+ path := dir + "/config.yaml"
215
+ if err := os.WriteFile(path, []byte("taskPlugin: jira\nrunnerPlugin: orca\nharnessPlugin: opencode\n"), 0600); err != nil {
216
+ t.Fatal(err)
217
+ }
218
+ cfg, err := config.LoadMachine(path)
219
+ if err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ if cfg.PollIntervalSeconds != 15 {
223
+ t.Fatalf("PollIntervalSeconds = %d, want default 15", cfg.PollIntervalSeconds)
224
+ }
225
+ }
226
+
227
+ func TestPollerGroupObservesReposRegisteredAfterStart(t *testing.T) {
228
+ // 9.8: Run must apply ReplaceRepos to the LIVE loop. A repo registered
229
+ // after serve starts (after Run is already blocking) must be polled
230
+ // within one poll interval, without restarting Run.
231
+ initial := &pollSystem{}
232
+ g := repo.NewPollerGroup(10, func(context.Context, *repo.Repo, []task.Ticket) {})
233
+ g.Interval = 30 * time.Millisecond
234
+ g.ReplaceRepos([]*repo.Repo{makeRepo("initial", initial)})
235
+
236
+ ctx, cancel := context.WithCancel(context.Background())
237
+ defer cancel()
238
+ go g.Run(ctx)
239
+
240
+ // Wait for the initial repo's first poll so Run is known to be live.
241
+ deadline := time.Now().Add(2 * time.Second)
242
+ for initial.polls.Load() == 0 && time.Now().Before(deadline) {
243
+ time.Sleep(5 * time.Millisecond)
244
+ }
245
+ if initial.polls.Load() == 0 {
246
+ t.Fatal("initial repo never polled; Run not live")
247
+ }
248
+
249
+ // Register a new repo AFTER Run is already running (the serve path:
250
+ // repo register via API -> onReposChanged -> ReplaceRepos).
251
+ added := &pollSystem{}
252
+ g.ReplaceRepos([]*repo.Repo{makeRepo("initial", initial), makeRepo("added", added)})
253
+
254
+ // It must be polled within one interval.
255
+ deadline = time.Now().Add(5 * g.Interval)
256
+ for added.polls.Load() == 0 && time.Now().Before(deadline) {
257
+ time.Sleep(5 * time.Millisecond)
258
+ }
259
+ if added.polls.Load() == 0 {
260
+ t.Fatal("repo registered after start was never polled; ReplaceRepos not applied to live loop")
261
+ }
262
+ }
263
+
264
+ func TestPollerGroupStopsRemovedRepoPoller(t *testing.T) {
265
+ // 9.8 flip side: removing a repo cancels its poller. After removal and
266
+ // a grace window, the removed repo's poll count must stop growing.
267
+ a := &pollSystem{}
268
+ b := &pollSystem{}
269
+ g := repo.NewPollerGroup(10, func(context.Context, *repo.Repo, []task.Ticket) {})
270
+ g.Interval = 20 * time.Millisecond
271
+ ra := makeRepo("a", a)
272
+ g.ReplaceRepos([]*repo.Repo{ra, makeRepo("b", b)})
273
+
274
+ ctx, cancel := context.WithCancel(context.Background())
275
+ defer cancel()
276
+ go g.Run(ctx)
277
+
278
+ deadline := time.Now().Add(2 * time.Second)
279
+ for (a.polls.Load() == 0 || b.polls.Load() == 0) && time.Now().Before(deadline) {
280
+ time.Sleep(5 * time.Millisecond)
281
+ }
282
+ if a.polls.Load() == 0 || b.polls.Load() == 0 {
283
+ t.Fatal("both repos should have polled before removal")
284
+ }
285
+
286
+ g.ReplaceRepos([]*repo.Repo{ra})
287
+ time.Sleep(100 * time.Millisecond) // allow reconcile + cancel to land
288
+ before := b.polls.Load()
289
+ time.Sleep(3 * g.Interval)
290
+ if got := b.polls.Load(); got != before {
291
+ t.Fatalf("removed repo polled after removal: before=%d after=%d", before, got)
292
+ }
293
+ }
294
+
295
+ func TestRepoPollerOnlyFetchesAndHandles(t *testing.T) {
296
+ // A poller does no matching/claiming: it calls Poll and passes the batch
297
+ // to the handler unchanged. Routing/claiming live in the batch handler
298
+ // (task 5.3), not in the poller.
299
+ want := []task.Ticket{{ID: "1", Key: "PAY-101"}}
300
+ s := &pollSystem{batch: want}
301
+ s.t = t // fail the test if the poller invokes any matching/claiming call
302
+ r := makeRepo("a", s)
303
+
304
+ got := make(chan []task.Ticket, 1)
305
+ p := &repo.RepoPoller{
306
+ Repo: r,
307
+ Interval: 20 * time.Millisecond,
308
+ Handle: func(_ context.Context, _ *repo.Repo, batch []task.Ticket) {
309
+ select {
310
+ case got <- batch:
311
+ default:
312
+ }
313
+ },
314
+ }
315
+ ctx, cancel := context.WithCancel(context.Background())
316
+ defer cancel()
317
+ go p.Run(ctx)
318
+
319
+ select {
320
+ case batch := <-got:
321
+ if len(batch) != len(want) || batch[0].Key != want[0].Key {
322
+ t.Fatalf("handler received %v, want the poll batch %v unchanged", batch, want)
323
+ }
324
+ case <-time.After(2 * time.Second):
325
+ t.Fatal("poller never delivered a batch to the handler")
326
+ }
327
+ }
@@ -0,0 +1,119 @@
1
+ // Package repo owns registered repos: the in-memory Repo, the derived
2
+ // repo-to-workflow index, registration/removal, and per-repo pollers.
3
+ package repo
4
+
5
+ import (
6
+ "fmt"
7
+ "sort"
8
+ "sync"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/task"
12
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
+ )
14
+
15
+ // WorkflowBinding pairs a workflow with its compiled ticket matcher for one
16
+ // repo. Repo.Workflows is a derived in-memory index rebuilt at startup and
17
+ // after workflow submission/removal; Workflow.Repos is the source of truth.
18
+ type WorkflowBinding struct {
19
+ Workflow *workflow.Workflow
20
+ Match func(task.Ticket) bool
21
+ }
22
+
23
+ type Info struct {
24
+ Name string `json:"name"`
25
+ Path string `json:"path"`
26
+ TaskConfig config.RawValues `json:"taskConfig,omitempty"`
27
+ }
28
+
29
+ type Repo struct {
30
+ Name string
31
+ Path string
32
+ TaskConfig config.RawValues
33
+ TaskSystem task.System
34
+ Workflows []WorkflowBinding
35
+ }
36
+
37
+ func (r *Repo) Info() Info {
38
+ return Info{Name: r.Name, Path: r.Path, TaskConfig: r.TaskConfig}
39
+ }
40
+
41
+ // Registry is the in-memory repo set.
42
+ type Registry struct {
43
+ mu sync.RWMutex
44
+ byID map[string]*Repo
45
+ }
46
+
47
+ func NewRegistry() *Registry {
48
+ return &Registry{byID: map[string]*Repo{}}
49
+ }
50
+
51
+ func (r *Registry) Get(name string) (*Repo, bool) {
52
+ r.mu.RLock()
53
+ defer r.mu.RUnlock()
54
+ rp, ok := r.byID[name]
55
+ return rp, ok
56
+ }
57
+
58
+ func (r *Registry) List() []*Repo {
59
+ r.mu.RLock()
60
+ defer r.mu.RUnlock()
61
+ out := make([]*Repo, 0, len(r.byID))
62
+ for _, rp := range r.byID {
63
+ out = append(out, rp)
64
+ }
65
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
66
+ return out
67
+ }
68
+
69
+ func (r *Registry) Replace(repo *Repo) {
70
+ r.mu.Lock()
71
+ defer r.mu.Unlock()
72
+ if r.byID == nil {
73
+ r.byID = map[string]*Repo{}
74
+ }
75
+ r.byID[repo.Name] = repo
76
+ }
77
+
78
+ func (r *Registry) Remove(name string) {
79
+ r.mu.Lock()
80
+ defer r.mu.Unlock()
81
+ delete(r.byID, name)
82
+ }
83
+
84
+ // BindWorkflows rebuilds the derived Repo.Workflows index from the given
85
+ // workflows. Each repo that a workflow lists gets a binding with the
86
+ // matcher compiled by that repo's task system. Repos not listed by a
87
+ // workflow keep no binding for it.
88
+ func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
89
+ type binding struct {
90
+ wf *workflow.Workflow
91
+ match func(task.Ticket) bool
92
+ }
93
+ byRepo := map[string][]binding{}
94
+ for _, wf := range workflows {
95
+ for _, repoName := range wf.Repos {
96
+ rp, ok := r.Get(repoName)
97
+ if !ok {
98
+ return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
99
+ }
100
+ match, err := rp.TaskSystem.CompileFilter(wf.TaskConfig)
101
+ if err != nil {
102
+ return fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
103
+ }
104
+ byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match})
105
+ }
106
+ }
107
+ r.mu.Lock()
108
+ defer r.mu.Unlock()
109
+ for name, rp := range r.byID {
110
+ binds := byRepo[name]
111
+ next := make([]WorkflowBinding, 0, len(binds))
112
+ for _, b := range binds {
113
+ next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match})
114
+ }
115
+ sort.Slice(next, func(i, j int) bool { return next[i].Workflow.Name < next[j].Workflow.Name })
116
+ rp.Workflows = next
117
+ }
118
+ return nil
119
+ }