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,266 @@
|
|
|
1
|
+
package run_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
10
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// 3.12: claim ordering per specs/repo-workflow-routing "Claim is persisted
|
|
17
|
+
// before run creation" and docs RunManager.EnsureRun ordering.
|
|
18
|
+
|
|
19
|
+
// eventLog is a shared ordered log across the fake task system and fake
|
|
20
|
+
// executor, proving claim-before-run-creation ordering.
|
|
21
|
+
type eventLog struct {
|
|
22
|
+
mu chan struct{}
|
|
23
|
+
events []string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func newEventLog() *eventLog { return &eventLog{mu: make(chan struct{}, 1)} }
|
|
27
|
+
|
|
28
|
+
func (l *eventLog) add(e string) {
|
|
29
|
+
l.mu <- struct{}{}
|
|
30
|
+
l.events = append(l.events, e)
|
|
31
|
+
<-l.mu
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (l *eventLog) all() []string {
|
|
35
|
+
l.mu <- struct{}{}
|
|
36
|
+
defer func() { <-l.mu }()
|
|
37
|
+
return append([]string{}, l.events...)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// recordingSystem records Claim/HasComment calls into the shared log.
|
|
41
|
+
type recordingSystem struct {
|
|
42
|
+
task.System
|
|
43
|
+
log *eventLog
|
|
44
|
+
claimErr error
|
|
45
|
+
hasCancel bool
|
|
46
|
+
hasCommentN int
|
|
47
|
+
markers []string // markers passed to HasComment, in order
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func (s *recordingSystem) Claim(_ context.Context, ref task.TicketRef, wf string) error {
|
|
51
|
+
s.log.add("claim:" + ref.Key + ":" + wf)
|
|
52
|
+
return s.claimErr
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (s *recordingSystem) HasComment(_ context.Context, _ task.Target, marker string) (bool, error) {
|
|
56
|
+
s.hasCommentN++
|
|
57
|
+
s.markers = append(s.markers, marker)
|
|
58
|
+
return s.hasCancel, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// fakeExecutor records EnsureRun calls into the shared log.
|
|
62
|
+
type fakeExecutor struct {
|
|
63
|
+
log *eventLog
|
|
64
|
+
ensures []run.Start
|
|
65
|
+
created bool
|
|
66
|
+
err error
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (e *fakeExecutor) EnsureRun(_ context.Context, start run.Start) (bool, error) {
|
|
70
|
+
e.log.add("ensureRun:" + string(start.ID))
|
|
71
|
+
e.ensures = append(e.ensures, start)
|
|
72
|
+
return e.created, e.err
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func (e *fakeExecutor) SubmitReport(context.Context, run.ReportRequest) (run.ReportAck, error) {
|
|
76
|
+
return run.ReportAck{}, nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (e *fakeExecutor) CancelRun(context.Context, run.ID, string) error { return nil }
|
|
80
|
+
|
|
81
|
+
// fakeQueries answers run lookups.
|
|
82
|
+
type fakeQueries struct {
|
|
83
|
+
byTicket map[string]run.Run
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (q *fakeQueries) GetRun(_ context.Context, id run.ID) (run.Run, error) {
|
|
87
|
+
for _, r := range q.byTicket {
|
|
88
|
+
if r.ID == id {
|
|
89
|
+
return r, nil
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return run.Run{}, errors.New("not found")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (q *fakeQueries) FindRunByTicket(_ context.Context, ticket string) (run.Run, error) {
|
|
96
|
+
if r, ok := q.byTicket[ticket]; ok {
|
|
97
|
+
return r, nil
|
|
98
|
+
}
|
|
99
|
+
return run.Run{}, errors.New("not found")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func (q *fakeQueries) ListRuns(context.Context, run.Filter) ([]run.Run, error) { return nil, nil }
|
|
103
|
+
func (q *fakeQueries) HasActiveWorkflow(context.Context, string) (bool, error) { return false, nil }
|
|
104
|
+
func (q *fakeQueries) HasActiveRepo(context.Context, string) (bool, error) { return false, nil }
|
|
105
|
+
|
|
106
|
+
func testWorkflow(name string) *workflow.Workflow {
|
|
107
|
+
return &workflow.Workflow{
|
|
108
|
+
Name: name,
|
|
109
|
+
Repos: []string{"payments"},
|
|
110
|
+
Nodes: map[string]workflow.Node{
|
|
111
|
+
"start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
|
|
112
|
+
"coding": {
|
|
113
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "work",
|
|
114
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
115
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
116
|
+
},
|
|
117
|
+
"end": {},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func testRepo(sys task.System) *repo.Repo {
|
|
123
|
+
return &repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func TestUnclaimedTicketClaimedBeforeEnsureRun(t *testing.T) {
|
|
127
|
+
log := newEventLog()
|
|
128
|
+
sys := &recordingSystem{log: log}
|
|
129
|
+
exec := &fakeExecutor{log: log, created: true}
|
|
130
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
|
|
131
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101", Title: "parent"}
|
|
132
|
+
|
|
133
|
+
err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket)
|
|
134
|
+
if err != nil {
|
|
135
|
+
t.Fatalf("EnsureRun failed: %v", err)
|
|
136
|
+
}
|
|
137
|
+
events := log.all()
|
|
138
|
+
if len(events) != 2 {
|
|
139
|
+
t.Fatalf("events = %v, want [claim ensureRun]", events)
|
|
140
|
+
}
|
|
141
|
+
// Claim is persisted before the durable run is created.
|
|
142
|
+
if !strings.HasPrefix(events[0], "claim:") || !strings.Contains(events[0], "basicFlow") {
|
|
143
|
+
t.Fatalf("first event = %q, want the wf:basicFlow claim", events[0])
|
|
144
|
+
}
|
|
145
|
+
if !strings.HasPrefix(events[1], "ensureRun:") {
|
|
146
|
+
t.Fatalf("second event = %q, want EnsureRun after the claim", events[1])
|
|
147
|
+
}
|
|
148
|
+
if !strings.Contains(events[0], "PAY-101") {
|
|
149
|
+
t.Fatalf("claim event = %q, want ticket PAY-101", events[0])
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func TestClaimedTicketSkipsClaiming(t *testing.T) {
|
|
154
|
+
log := newEventLog()
|
|
155
|
+
sys := &recordingSystem{log: log}
|
|
156
|
+
exec := &fakeExecutor{log: log}
|
|
157
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
|
|
158
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
|
|
159
|
+
|
|
160
|
+
err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket)
|
|
161
|
+
if err != nil {
|
|
162
|
+
t.Fatalf("EnsureRun failed: %v", err)
|
|
163
|
+
}
|
|
164
|
+
for _, e := range log.all() {
|
|
165
|
+
if strings.HasPrefix(e, "claim:") {
|
|
166
|
+
t.Fatalf("already-claimed ticket re-claimed: %v", log.all())
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if len(exec.ensures) != 1 {
|
|
170
|
+
t.Fatalf("executor ensures = %d, want 1 (run still ensured)", len(exec.ensures))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestClaimFailurePreventsRunCreation(t *testing.T) {
|
|
175
|
+
log := newEventLog()
|
|
176
|
+
sys := &recordingSystem{log: log, claimErr: errors.New("jira down")}
|
|
177
|
+
exec := &fakeExecutor{log: log}
|
|
178
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
|
|
179
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101"}
|
|
180
|
+
|
|
181
|
+
err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket)
|
|
182
|
+
if err == nil {
|
|
183
|
+
t.Fatal("EnsureRun succeeded despite claim failure")
|
|
184
|
+
}
|
|
185
|
+
for _, e := range log.all() {
|
|
186
|
+
if strings.HasPrefix(e, "ensureRun:") {
|
|
187
|
+
t.Fatal("run created after claim failure; claim must succeed first")
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func TestCancellationMarkerSkipsRunCreation(t *testing.T) {
|
|
193
|
+
// A labeled ticket with a missing run but carrying the
|
|
194
|
+
// <runID>:cancellation marker must not be recreated.
|
|
195
|
+
log := newEventLog()
|
|
196
|
+
sys := &recordingSystem{log: log, hasCancel: true}
|
|
197
|
+
exec := &fakeExecutor{log: log}
|
|
198
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{byTicket: map[string]run.Run{}}}
|
|
199
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
|
|
200
|
+
|
|
201
|
+
err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket)
|
|
202
|
+
if err != nil {
|
|
203
|
+
t.Fatalf("EnsureRun failed: %v", err)
|
|
204
|
+
}
|
|
205
|
+
for _, e := range log.all() {
|
|
206
|
+
if strings.HasPrefix(e, "ensureRun:") {
|
|
207
|
+
t.Fatal("run recreated for cancellation-marked parent; marker must skip creation")
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if sys.hasCommentN == 0 {
|
|
211
|
+
t.Fatal("Run Manager never checked the cancellation marker before creating a missing claimed run")
|
|
212
|
+
}
|
|
213
|
+
// The marker checked is exactly the deterministic <runID>:cancellation.
|
|
214
|
+
wantMarker := string(identity.NewRunID("payments", "basicFlow", "PAY-101")) + ":cancellation"
|
|
215
|
+
found := false
|
|
216
|
+
for _, mk := range sys.markers {
|
|
217
|
+
if mk == wantMarker {
|
|
218
|
+
found = true
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if !found {
|
|
222
|
+
t.Fatalf("cancellation marker checked = %v, want %q", sys.markers, wantMarker)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func TestDeterministicRunID(t *testing.T) {
|
|
227
|
+
log := newEventLog()
|
|
228
|
+
sys := &recordingSystem{log: log}
|
|
229
|
+
exec := &fakeExecutor{log: log, created: true}
|
|
230
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
|
|
231
|
+
ticket := task.Ticket{ID: "1", Key: "PAY-101"}
|
|
232
|
+
|
|
233
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket); err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
want := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
237
|
+
if exec.ensures[0].ID != want {
|
|
238
|
+
t.Fatalf("run ID = %q, want deterministic %q", exec.ensures[0].ID, want)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
func TestCancelByTicketResolvesActiveRun(t *testing.T) {
|
|
243
|
+
rid := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
244
|
+
queries := &fakeQueries{byTicket: map[string]run.Run{
|
|
245
|
+
"PAY-101": {ID: rid, Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateWaiting},
|
|
246
|
+
}}
|
|
247
|
+
exec := &cancelRecorder{}
|
|
248
|
+
m := &run.RunManager{Executor: exec, Runs: queries}
|
|
249
|
+
|
|
250
|
+
if err := m.CancelByTicket(context.Background(), "PAY-101", "no longer needed"); err != nil {
|
|
251
|
+
t.Fatalf("CancelByTicket failed: %v", err)
|
|
252
|
+
}
|
|
253
|
+
if exec.canceled != rid {
|
|
254
|
+
t.Fatalf("canceled run = %q, want %q", exec.canceled, rid)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type cancelRecorder struct {
|
|
259
|
+
fakeExecutor
|
|
260
|
+
canceled run.ID
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func (e *cancelRecorder) CancelRun(_ context.Context, id run.ID, _ string) error {
|
|
264
|
+
e.canceled = id
|
|
265
|
+
return nil
|
|
266
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
package runner_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// 3.7: runner contract tests against a fake runner, per
|
|
13
|
+
// specs/integration-contracts "Runner executes harnesses in ticket-scoped
|
|
14
|
+
// environments" and "Runner terminal titles are stable and minimal".
|
|
15
|
+
|
|
16
|
+
type fakeRunner struct {
|
|
17
|
+
envs map[string]runner.Environment
|
|
18
|
+
terminals map[string]runner.Terminal // key: envID/title
|
|
19
|
+
dead map[string]bool // terminal key -> unusable
|
|
20
|
+
closed []string
|
|
21
|
+
cleaned []string
|
|
22
|
+
ensureN int
|
|
23
|
+
ensureEnvN int
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func newFakeRunner() *fakeRunner {
|
|
27
|
+
return &fakeRunner{
|
|
28
|
+
envs: map[string]runner.Environment{},
|
|
29
|
+
terminals: map[string]runner.Terminal{},
|
|
30
|
+
dead: map[string]bool{},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func spec() runner.RunSpec {
|
|
35
|
+
return runner.RunSpec{
|
|
36
|
+
RunID: identity.NewRunID("payments", "basicFlow", "PAY-101"),
|
|
37
|
+
RepoName: "payments",
|
|
38
|
+
RepoPath: "/srv/payments",
|
|
39
|
+
TicketKey: "PAY-101",
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (f *fakeRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
44
|
+
return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (f *fakeRunner) ValidateRepo(context.Context, string, string) error { return nil }
|
|
48
|
+
|
|
49
|
+
func (f *fakeRunner) EnsureEnvironment(_ context.Context, s runner.RunSpec) (runner.Environment, error) {
|
|
50
|
+
f.ensureEnvN++
|
|
51
|
+
key := string(s.RunID)
|
|
52
|
+
if env, ok := f.envs[key]; ok {
|
|
53
|
+
return env, nil
|
|
54
|
+
}
|
|
55
|
+
env := runner.Environment{ID: "env-" + key, Path: s.RepoPath + "/.wt/" + s.TicketKey}
|
|
56
|
+
f.envs[key] = env
|
|
57
|
+
return env, nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (f *fakeRunner) FindTerminal(_ context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
|
|
61
|
+
key := env.ID + "/" + title
|
|
62
|
+
term, ok := f.terminals[key]
|
|
63
|
+
if !ok || f.dead[key] {
|
|
64
|
+
// Only a live usable terminal is returned.
|
|
65
|
+
return runner.Terminal{}, false, nil
|
|
66
|
+
}
|
|
67
|
+
return term, true, nil
|
|
68
|
+
}
|
|
69
|
+
func (f *fakeRunner) InspectTerminal(_ context.Context, term runner.Terminal) (runner.Terminal, bool, error) {
|
|
70
|
+
for key, current := range f.terminals {
|
|
71
|
+
if current.ID == term.ID && !f.dead[key] {
|
|
72
|
+
return current, true, nil
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return runner.Terminal{}, false, nil
|
|
76
|
+
}
|
|
77
|
+
func (f *fakeRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
|
|
78
|
+
func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
79
|
+
return f.EnsureTerminal(ctx, env, title, command)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (f *fakeRunner) CloseTerminal(_ context.Context, term runner.Terminal) error {
|
|
83
|
+
for k, v := range f.terminals {
|
|
84
|
+
if v.ID == term.ID {
|
|
85
|
+
delete(f.terminals, k)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
f.closed = append(f.closed, term.ID)
|
|
89
|
+
return nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func (f *fakeRunner) EnsureTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
|
|
93
|
+
f.ensureN++
|
|
94
|
+
key := env.ID + "/" + title
|
|
95
|
+
if term, ok := f.terminals[key]; ok && !f.dead[key] {
|
|
96
|
+
return term, nil
|
|
97
|
+
}
|
|
98
|
+
term := runner.Terminal{ID: "term-" + key, Title: title}
|
|
99
|
+
f.terminals[key] = term
|
|
100
|
+
delete(f.dead, key)
|
|
101
|
+
return term, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func (f *fakeRunner) CloseTerminals(_ context.Context, s runner.RunSpec) error {
|
|
105
|
+
prefix := "env-" + string(s.RunID) + "/"
|
|
106
|
+
for k := range f.terminals {
|
|
107
|
+
if strings.HasPrefix(k, prefix) {
|
|
108
|
+
delete(f.terminals, k)
|
|
109
|
+
f.closed = append(f.closed, k)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Environment/workspace preserved: f.envs untouched.
|
|
113
|
+
return nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (f *fakeRunner) CleanupRun(_ context.Context, s runner.RunSpec) error {
|
|
117
|
+
_ = f.CloseTerminals(context.Background(), s)
|
|
118
|
+
delete(f.envs, string(s.RunID))
|
|
119
|
+
f.cleaned = append(f.cleaned, string(s.RunID))
|
|
120
|
+
return nil
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestTerminalTitleIsTicketColonNode(t *testing.T) {
|
|
124
|
+
f := newFakeRunner()
|
|
125
|
+
ctx := context.Background()
|
|
126
|
+
sp := spec()
|
|
127
|
+
env, err := f.EnsureEnvironment(ctx, sp)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
term, err := f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{Executable: "opencode"})
|
|
132
|
+
if err != nil {
|
|
133
|
+
t.Fatal(err)
|
|
134
|
+
}
|
|
135
|
+
if term.Title != "PAY-101:coding" {
|
|
136
|
+
t.Fatalf("title = %q, want PAY-101:coding", term.Title)
|
|
137
|
+
}
|
|
138
|
+
// Title must never carry nodeVisitID, workflow, or agent metadata.
|
|
139
|
+
visit := identity.NewNodeVisitID()
|
|
140
|
+
for _, forbidden := range []string{string(visit), "basicFlow", "build"} {
|
|
141
|
+
if strings.Contains(term.Title, forbidden) {
|
|
142
|
+
t.Fatalf("title %q contains forbidden metadata %q", term.Title, forbidden)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestFindTerminalReturnsOnlyLiveUsable(t *testing.T) {
|
|
148
|
+
f := newFakeRunner()
|
|
149
|
+
ctx := context.Background()
|
|
150
|
+
env, _ := f.EnsureEnvironment(ctx, spec())
|
|
151
|
+
term, _ := f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
152
|
+
|
|
153
|
+
got, ok, err := f.FindTerminal(ctx, env, "PAY-101:coding")
|
|
154
|
+
if err != nil || !ok || got.ID != term.ID {
|
|
155
|
+
t.Fatalf("FindTerminal = %v,%v,%v; want live terminal", got, ok, err)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A stale/dead record is treated as absent.
|
|
159
|
+
f.dead[env.ID+"/PAY-101:coding"] = true
|
|
160
|
+
_, ok, err = f.FindTerminal(ctx, env, "PAY-101:coding")
|
|
161
|
+
if err != nil {
|
|
162
|
+
t.Fatal(err)
|
|
163
|
+
}
|
|
164
|
+
if ok {
|
|
165
|
+
t.Fatal("FindTerminal returned a dead terminal as usable")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func TestCloseTerminalsPreservesEnvironment(t *testing.T) {
|
|
170
|
+
f := newFakeRunner()
|
|
171
|
+
ctx := context.Background()
|
|
172
|
+
sp := spec()
|
|
173
|
+
env, _ := f.EnsureEnvironment(ctx, sp)
|
|
174
|
+
_, _ = f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
175
|
+
|
|
176
|
+
if err := f.CloseTerminals(ctx, sp); err != nil {
|
|
177
|
+
t.Fatal(err)
|
|
178
|
+
}
|
|
179
|
+
if len(f.terminals) != 0 {
|
|
180
|
+
t.Fatalf("terminals remain after CloseTerminals: %v", f.terminals)
|
|
181
|
+
}
|
|
182
|
+
if _, ok := f.envs[string(sp.RunID)]; !ok {
|
|
183
|
+
t.Fatal("CloseTerminals removed the environment; it must preserve workspace/code")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func TestCleanupRunRemovesAllRunResources(t *testing.T) {
|
|
188
|
+
f := newFakeRunner()
|
|
189
|
+
ctx := context.Background()
|
|
190
|
+
sp := spec()
|
|
191
|
+
env, _ := f.EnsureEnvironment(ctx, sp)
|
|
192
|
+
_, _ = f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
|
|
193
|
+
|
|
194
|
+
if err := f.CleanupRun(ctx, sp); err != nil {
|
|
195
|
+
t.Fatal(err)
|
|
196
|
+
}
|
|
197
|
+
if len(f.terminals) != 0 {
|
|
198
|
+
t.Fatalf("terminals remain after CleanupRun: %v", f.terminals)
|
|
199
|
+
}
|
|
200
|
+
if _, ok := f.envs[string(sp.RunID)]; ok {
|
|
201
|
+
t.Fatal("environment remains after CleanupRun; it must remove run-owned resources")
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func TestEnsureIdempotent(t *testing.T) {
|
|
206
|
+
f := newFakeRunner()
|
|
207
|
+
ctx := context.Background()
|
|
208
|
+
sp := spec()
|
|
209
|
+
|
|
210
|
+
env1, _ := f.EnsureEnvironment(ctx, sp)
|
|
211
|
+
env2, _ := f.EnsureEnvironment(ctx, sp)
|
|
212
|
+
if env1 != env2 {
|
|
213
|
+
t.Fatalf("EnsureEnvironment not idempotent: %v vs %v", env1, env2)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
term1, _ := f.EnsureTerminal(ctx, env1, "PAY-101:coding", runner.Command{})
|
|
217
|
+
term2, _ := f.EnsureTerminal(ctx, env1, "PAY-101:coding", runner.Command{})
|
|
218
|
+
if term1 != term2 {
|
|
219
|
+
t.Fatalf("EnsureTerminal not idempotent: %v vs %v", term1, term2)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package runner
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"sort"
|
|
6
|
+
"strings"
|
|
7
|
+
"sync"
|
|
8
|
+
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Factory constructs a Runner from root runner config. No repo-level runner
|
|
13
|
+
// config exists; the runner resolves internal workspace IDs from the
|
|
14
|
+
// registered repo name and path.
|
|
15
|
+
type Factory func(config.RawValues) (Runner, error)
|
|
16
|
+
|
|
17
|
+
var (
|
|
18
|
+
registryMu sync.RWMutex
|
|
19
|
+
registry = map[string]Factory{}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Register adds a runner factory by name. Duplicate registration panics.
|
|
23
|
+
func Register(name string, factory Factory) {
|
|
24
|
+
registryMu.Lock()
|
|
25
|
+
defer registryMu.Unlock()
|
|
26
|
+
if _, exists := registry[name]; exists {
|
|
27
|
+
panic(fmt.Sprintf("runner: duplicate registration of %q", name))
|
|
28
|
+
}
|
|
29
|
+
registry[name] = factory
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// New constructs the named runner.
|
|
33
|
+
func New(name string, cfg config.RawValues) (Runner, error) {
|
|
34
|
+
registryMu.RLock()
|
|
35
|
+
f, ok := registry[name]
|
|
36
|
+
registryMu.RUnlock()
|
|
37
|
+
if !ok {
|
|
38
|
+
return nil, fmt.Errorf("runner: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
39
|
+
}
|
|
40
|
+
return f(cfg)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ValidateName returns an error listing registered names when name is not
|
|
44
|
+
// a registered plugin. Used by `relay-flow init` to reject unknown plugin
|
|
45
|
+
// selections without constructing a Runner.
|
|
46
|
+
func ValidateName(name string) error {
|
|
47
|
+
registryMu.RLock()
|
|
48
|
+
defer registryMu.RUnlock()
|
|
49
|
+
if _, ok := registry[name]; !ok {
|
|
50
|
+
return fmt.Errorf("runner: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
51
|
+
}
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Names returns the registered plugin names sorted.
|
|
56
|
+
func Names() []string {
|
|
57
|
+
registryMu.RLock()
|
|
58
|
+
defer registryMu.RUnlock()
|
|
59
|
+
out := make([]string, 0, len(registry))
|
|
60
|
+
for name := range registry {
|
|
61
|
+
out = append(out, name)
|
|
62
|
+
}
|
|
63
|
+
sort.Strings(out)
|
|
64
|
+
return out
|
|
65
|
+
}
|