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