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,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
|
+
}
|