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,1135 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "path/filepath"
7
+ "sort"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/rajpopat27/relay-flow/internal/config"
14
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
15
+ "github.com/rajpopat27/relay-flow/internal/harness"
16
+ "github.com/rajpopat27/relay-flow/internal/identity"
17
+ "github.com/rajpopat27/relay-flow/internal/repo"
18
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
19
+ "github.com/rajpopat27/relay-flow/internal/runner"
20
+ "github.com/rajpopat27/relay-flow/internal/task"
21
+ "github.com/rajpopat27/relay-flow/internal/workflow"
22
+ )
23
+
24
+ const scenarioTaskPlugin = "scenario-e2e-task"
25
+
26
+ var (
27
+ scenarioFactoryMu sync.Mutex
28
+ scenarioFactorySystem task.System
29
+ )
30
+
31
+ func init() {
32
+ task.Register(scenarioTaskPlugin, task.Factory{
33
+ RequiredRepoKeys: func() []string { return nil },
34
+ TaskScopeKey: func(config.RawValues, config.RawValues) (string, error) {
35
+ return "scenario-scope", nil
36
+ },
37
+ New: func(context.Context, task.RepoSpec) (task.System, error) {
38
+ scenarioFactoryMu.Lock()
39
+ defer scenarioFactoryMu.Unlock()
40
+ if scenarioFactorySystem == nil {
41
+ return nil, errors.New("scenario task system not configured")
42
+ }
43
+ return scenarioFactorySystem, nil
44
+ },
45
+ })
46
+ }
47
+
48
+ func setScenarioFactorySystem(system task.System) {
49
+ scenarioFactoryMu.Lock()
50
+ scenarioFactorySystem = system
51
+ scenarioFactoryMu.Unlock()
52
+ }
53
+
54
+ // These scenarios exercise the same composition chain as serve:
55
+ // RepoPoller/handleBatch -> RunManager -> real go-workflows SQLite engine.
56
+ // The only replacements are the documented task, runner, and harness seams.
57
+
58
+ func TestScenarioHappyPath(t *testing.T) {
59
+ f := newScenarioFixture(t)
60
+ f.pollOnce()
61
+ f.waitNode("implement")
62
+
63
+ wantID := identity.NewRunID(scenarioRepo, scenarioWorkflowName, scenarioTicket)
64
+ if f.runID != wantID {
65
+ t.Fatalf("run ID = %q, want deterministic %q", f.runID, wantID)
66
+ }
67
+ assertBefore(t, f.log.all(), "claim:TEST-1:scenarioFlow", "run-created:"+string(f.runID))
68
+ assertMailboxDefinitions(t, f.tasks)
69
+ assertLaunch(t, f, "implement", workflow.NodeAgent)
70
+
71
+ f.submit(workflow.OutcomeSuccess, "verify")
72
+ f.waitNode("verify")
73
+ assertTransitionOrder(t, f.log.all(), "implement", "verify")
74
+ assertLaunch(t, f, "verify", workflow.NodeAgent)
75
+
76
+ f.submit(workflow.OutcomeSuccess, "pr-review")
77
+ f.waitNode("pr-review")
78
+ assertTransitionOrder(t, f.log.all(), "verify", "pr-review")
79
+ // The real launch metadata selects the production plugin's HITL silence
80
+ // path. The TypeScript plugin tests drive that path directly; this Go
81
+ // scenario does not invent a second nudge implementation.
82
+ assertLaunch(t, f, "pr-review", workflow.NodeHITL)
83
+
84
+ f.submit(workflow.OutcomeSuccess, "end")
85
+ f.waitCompleted()
86
+ if got := f.runner.cleanupCount(); got != 1 {
87
+ t.Fatalf("CleanupRun calls = %d, want 1 with explicit cleanup policy", got)
88
+ }
89
+ assertExactHappyEffects(t, f)
90
+ }
91
+
92
+ func TestScenarioHITLRejectLoop(t *testing.T) {
93
+ f := newScenarioFixture(t)
94
+ f.pollOnce()
95
+ f.waitNode("implement")
96
+ f.submit(workflow.OutcomeSuccess, "verify")
97
+ f.waitNode("verify")
98
+ f.submit(workflow.OutcomeSuccess, "pr-review")
99
+ f.waitNode("pr-review")
100
+
101
+ firstImplementVisit := f.harness.launch("implement").NodeVisitID
102
+ f.submit(workflow.OutcomeFailure, "implement")
103
+ f.waitNodeWithNewVisit("implement", firstImplementVisit)
104
+ if f.tasks.mailboxCreateCount("implement") != 1 {
105
+ t.Fatal("reject loop created a second implement mailbox")
106
+ }
107
+ if got := f.tasks.commentCount("implement", "feedback"); got != 1 {
108
+ t.Fatalf("reject feedback on reopened implement mailbox = %d, want 1", got)
109
+ }
110
+ if got := f.runner.launchCount("TEST-1:implement"); got != 2 {
111
+ t.Fatalf("implement terminal launches = %d, want 2 with explicit terminal checkpointing", got)
112
+ }
113
+
114
+ f.submit(workflow.OutcomeSuccess, "verify")
115
+ f.waitNode("verify")
116
+ f.submit(workflow.OutcomeSuccess, "pr-review")
117
+ f.waitNode("pr-review")
118
+ f.submit(workflow.OutcomeSuccess, "end")
119
+ f.waitCompleted()
120
+
121
+ if got := f.tasks.commentCount("implement", "summary"); got != 2 {
122
+ t.Fatalf("implement summaries = %d, want one per pass", got)
123
+ }
124
+ if got := f.tasks.commentCount("verify", "summary"); got != 2 {
125
+ t.Fatalf("verify summaries = %d, want one per pass", got)
126
+ }
127
+ if got := f.tasks.commentCount("pr-review", "summary"); got != 2 {
128
+ t.Fatalf("pr-review summaries = %d, want one per pass", got)
129
+ }
130
+ for node, want := range map[string]int{"implement": 1, "verify": 2, "pr-review": 2, "parent": 0} {
131
+ if got := f.tasks.commentCount(node, "feedback"); got != want {
132
+ t.Fatalf("%s feedback comments = %d, want exactly %d", node, got, want)
133
+ }
134
+ }
135
+ if got := f.tasks.totalComments(); got != 11 {
136
+ t.Fatalf("loop comments = %d, want exactly 11 (one summary and selected feedback per pass)", got)
137
+ }
138
+ if got := f.tasks.totalMailboxCreates(); got != 3 {
139
+ t.Fatalf("mailboxes created = %d, want three reusable mailboxes", got)
140
+ }
141
+ }
142
+
143
+ func TestScenarioAgentFailureRoutingAndInvalidNudge(t *testing.T) {
144
+ f := newScenarioFixture(t)
145
+ f.pollOnce()
146
+ f.waitNode("implement")
147
+
148
+ before := f.projection()
149
+ bad := scenarioReport(workflow.OutcomeFailure, "verify") // success-only target
150
+ ack, err := f.engine.SubmitReport(context.Background(), runsvc.ReportRequest{
151
+ RunID: f.runID, Node: before.CurrentNode, ReportID: "invalid-route", Report: bad,
152
+ })
153
+ if err == nil || ack.Accepted {
154
+ t.Fatalf("failure report naming success-only target accepted: ack=%+v err=%v", ack, err)
155
+ }
156
+ // Production plugin tests exercise invalid output -> session API nudge.
157
+ // Here the real server boundary must reject it without persistence.
158
+ after := f.projection()
159
+ if after.CurrentNodeVisitID != before.CurrentNodeVisitID || after.CurrentNode != "implement" {
160
+ t.Fatalf("invalid report changed projection: before=%+v after=%+v", before, after)
161
+ }
162
+ if got := f.tasks.totalComments(); got != 0 {
163
+ t.Fatalf("invalid report persisted graph effects: %d comments", got)
164
+ }
165
+
166
+ f.submit(workflow.OutcomeFailure, "pr-review")
167
+ f.waitNode("pr-review")
168
+ if got := f.tasks.commentCount("pr-review", "feedback"); got != 1 {
169
+ t.Fatalf("failure feedback on configured failure target = %d, want 1", got)
170
+ }
171
+ if got := f.runner.launchCount("TEST-1:verify"); got != 0 {
172
+ t.Fatalf("failure followed success route and launched verify %d times", got)
173
+ }
174
+ f.submit(workflow.OutcomeSuccess, "end")
175
+ f.waitCompleted()
176
+ }
177
+
178
+ func TestScenarioCrashMidTransitionRollsForward(t *testing.T) {
179
+ f := newScenarioFixture(t)
180
+ f.pollOnce()
181
+ f.waitNode("implement")
182
+ f.tasks.setCompleteFailures(100)
183
+ f.submitWithoutWaiting(workflow.OutcomeSuccess, "verify")
184
+ f.waitEvent("complete-failed:TEST-1:implement")
185
+ if got := f.tasks.commentCount("implement", "summary"); got != 1 {
186
+ t.Fatalf("pre-crash summaries = %d, want 1", got)
187
+ }
188
+ if got := f.tasks.commentCount("verify", "feedback"); got != 1 {
189
+ t.Fatalf("pre-crash feedback = %d, want 1", got)
190
+ }
191
+
192
+ f.restart()
193
+ f.tasks.setCompleteFailures(0)
194
+ f.waitNode("verify")
195
+ if got := f.tasks.commentCount("implement", "summary"); got != 1 {
196
+ t.Fatalf("summary duplicated after restart: %d", got)
197
+ }
198
+ if got := f.tasks.commentCount("verify", "feedback"); got != 1 {
199
+ t.Fatalf("feedback duplicated after restart: %d", got)
200
+ }
201
+ if got := f.tasks.completeCount("implement"); got != 1 {
202
+ t.Fatalf("successful implement completions = %d, want 1", got)
203
+ }
204
+
205
+ f.submit(workflow.OutcomeSuccess, "pr-review")
206
+ f.waitNode("pr-review")
207
+ f.submit(workflow.OutcomeSuccess, "end")
208
+ f.waitCompleted()
209
+ assertNoDuplicateTransitionCalls(t, f.tasks.transitionCalls())
210
+ }
211
+
212
+ func TestScenarioLateRegisterAndLateSubmit(t *testing.T) {
213
+ log := newScenarioLog()
214
+ sys := newScenarioTaskSystem(log)
215
+ fr := newScenarioRunner(log)
216
+ fh := newScenarioHarness(log)
217
+ reg := repo.NewRegistry()
218
+ db := filepath.Join(t.TempDir(), "state.db")
219
+ engine, err := goworkflows.New(db, goworkflows.Dependencies{Repos: reg, Runner: fr, Harness: fh})
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ if err := engine.Start(context.Background()); err != nil {
224
+ t.Fatal(err)
225
+ }
226
+ t.Cleanup(func() { shutdownScenarioEngine(engine) })
227
+
228
+ gate := &sync.Mutex{}
229
+ manager := &runsvc.RunManager{Executor: scenarioExecutor{inner: engine, log: log}, Runs: engine, Gate: gate}
230
+ pollers := repo.NewPollerGroup(10, handleBatch(manager))
231
+ pollers.Interval = 100 * time.Millisecond
232
+ ctx, cancel := context.WithCancel(context.Background())
233
+ done := make(chan struct{})
234
+ go func() { pollers.Run(ctx); close(done) }()
235
+ t.Cleanup(func() { cancel(); <-done })
236
+
237
+ repoLookup := scenarioRepoLookup{reg: reg}
238
+ store := &workflow.Store{Dir: filepath.Join(t.TempDir(), "workflows")}
239
+ wfService := workflow.NewService(store, engine, repoLookup)
240
+ wfService.Gate = gate
241
+ wfService.ValidateTaskConfig = workflowConfigValidator(reg)
242
+ wfService.Rebind = func() error { return reg.BindWorkflows(wfService.Registry().List()) }
243
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
244
+ if err := config.SaveMachine(configPath, &config.Machine{
245
+ TaskPlugin: scenarioTaskPlugin, RunnerPlugin: "unused", HarnessPlugin: "unused",
246
+ Repos: map[string]config.Repo{},
247
+ }); err != nil {
248
+ t.Fatal(err)
249
+ }
250
+ repoService := repo.NewServiceWithRegistry(repo.ServiceConfig{
251
+ ConfigPath: configPath, TaskPlugin: scenarioTaskPlugin, Runner: fr,
252
+ Active: engine, Workflows: wfService.Registry(),
253
+ }, reg)
254
+ deps := &serveDeps{
255
+ repos: repoService,
256
+ onReposChanged: func() { pollers.ReplaceRepos(repoService.Registry().List()) },
257
+ }
258
+
259
+ // Normative inverse case: submission before registration is rejected
260
+ // completely; no definition, binding, claim, or run leaks through.
261
+ if _, err := wfService.Submit(context.Background(), scenarioWorkflowYAML); err == nil || !strings.Contains(err.Error(), "unregistered repo") {
262
+ t.Fatalf("submit before registration error = %v, want unregistered repo rejection", err)
263
+ }
264
+ if len(wfService.List()) != 0 || log.countPrefix("claim:") != 0 {
265
+ t.Fatal("rejected workflow submission left observable state")
266
+ }
267
+
268
+ // Runtime registration goes through the real repo service and the same
269
+ // serve callback that updates the already-running poller group.
270
+ setScenarioFactorySystem(sys)
271
+ if _, err := deps.RegisterRepo(context.Background(), repo.RegisterInput{
272
+ Name: scenarioRepo, Path: scenarioRepoPath,
273
+ }); err != nil {
274
+ t.Fatalf("register repo: %v", err)
275
+ }
276
+ waitScenario(t, 2*time.Second, func() bool { return log.countPrefix("poll") > 0 })
277
+ if log.countPrefix("claim:") != 0 {
278
+ t.Fatal("repo registration claimed before a workflow was submitted")
279
+ }
280
+
281
+ // Registration first, then submission: the real Service rebuilds the
282
+ // live repo binding, and the next poll claims without a server restart.
283
+ if _, err := wfService.Submit(context.Background(), scenarioWorkflowYAML); err != nil {
284
+ t.Fatalf("submit after registration: %v", err)
285
+ }
286
+ log.add("workflow:submitted")
287
+ wantID := identity.NewRunID(scenarioRepo, scenarioWorkflowName, scenarioTicket)
288
+ waitScenario(t, 5*time.Second, func() bool {
289
+ r, err := engine.GetRun(context.Background(), wantID)
290
+ return err == nil && r.CurrentNode == "implement"
291
+ })
292
+ assertBefore(t, log.all(), "workflow:submitted", "claim:TEST-1:scenarioFlow")
293
+ assertBefore(t, log.all(), "claim:TEST-1:scenarioFlow", "run-created:"+string(wantID))
294
+
295
+ finishScenarioRun(t, engine, wantID)
296
+ r, err := engine.GetRun(context.Background(), wantID)
297
+ if err != nil || r.State != runsvc.StateCompleted {
298
+ t.Fatalf("late-register run = %+v err=%v, want completed", r, err)
299
+ }
300
+ }
301
+
302
+ const (
303
+ scenarioRepo = "fake-repo"
304
+ scenarioRepoPath = "/tmp/fake-repo"
305
+ scenarioWorkflowName = "scenarioFlow"
306
+ scenarioTicket = "TEST-1"
307
+ )
308
+
309
+ var scenarioWorkflowYAML = []byte(`name: scenarioFlow
310
+ repos: [fake-repo]
311
+ cleanupRunnerOnEnd: true
312
+ nodes:
313
+ start:
314
+ onSuccess: [{target: implement}]
315
+ implement:
316
+ type: agent
317
+ agent: implementer
318
+ description: Implement the requested change.
319
+ onSuccess: [{target: verify}]
320
+ onFailure: [{target: pr-review, when: implementation cannot proceed}]
321
+ verify:
322
+ type: agent
323
+ agent: verifier
324
+ description: Verify the implementation.
325
+ onSuccess: [{target: pr-review}]
326
+ onFailure: [{target: implement, when: verification fails}]
327
+ pr-review:
328
+ type: hitl
329
+ agent: reviewer
330
+ description: Review and approve the pull request.
331
+ onSuccess: [{target: end}]
332
+ onFailure: [{target: implement, when: changes are requested}]
333
+ end: {}
334
+ `)
335
+
336
+ func scenarioWorkflow() workflow.Workflow {
337
+ wf, err := workflow.Parse("", scenarioWorkflowYAML)
338
+ if err != nil {
339
+ panic(err)
340
+ }
341
+ if err := wf.Validate(); err != nil {
342
+ panic(err)
343
+ }
344
+ return *wf
345
+ }
346
+
347
+ type scenarioFixture struct {
348
+ t *testing.T
349
+ log *scenarioLog
350
+ tasks *scenarioTaskSystem
351
+ runner *scenarioRunner
352
+ harness *scenarioHarness
353
+ reg *repo.Registry
354
+ repo *repo.Repo
355
+ wf workflow.Workflow
356
+ db string
357
+ engine *goworkflows.Engine
358
+ manager *runsvc.RunManager
359
+ runID runsvc.ID
360
+ }
361
+
362
+ func newScenarioFixture(t *testing.T) *scenarioFixture {
363
+ t.Helper()
364
+ f := &scenarioFixture{t: t, log: newScenarioLog(), wf: scenarioWorkflow()}
365
+ f.tasks = newScenarioTaskSystem(f.log)
366
+ f.runner = newScenarioRunner(f.log)
367
+ f.harness = newScenarioHarness(f.log)
368
+ f.reg = repo.NewRegistry()
369
+ f.repo = &repo.Repo{Name: scenarioRepo, Path: scenarioRepoPath, TaskSystem: f.tasks}
370
+ f.reg.Replace(f.repo)
371
+ if err := f.reg.BindWorkflows([]*workflow.Workflow{&f.wf}); err != nil {
372
+ t.Fatal(err)
373
+ }
374
+ f.db = filepath.Join(t.TempDir(), "state.db")
375
+ f.engine = f.openEngine()
376
+ f.manager = &runsvc.RunManager{Executor: scenarioExecutor{inner: f.engine, log: f.log}, Runs: f.engine, Gate: &sync.Mutex{}}
377
+ f.runID = identity.NewRunID(scenarioRepo, scenarioWorkflowName, scenarioTicket)
378
+ t.Cleanup(func() { shutdownScenarioEngine(f.engine) })
379
+ return f
380
+ }
381
+
382
+ func (f *scenarioFixture) openEngine() *goworkflows.Engine {
383
+ f.t.Helper()
384
+ e, err := goworkflows.New(f.db, goworkflows.Dependencies{
385
+ Repos: f.reg, Runner: f.runner, Harness: f.harness,
386
+ Runtime: &runsvc.RuntimePolicy{},
387
+ })
388
+ if err != nil {
389
+ f.t.Fatal(err)
390
+ }
391
+ if err := e.Start(context.Background()); err != nil {
392
+ f.t.Fatal(err)
393
+ }
394
+ return e
395
+ }
396
+
397
+ func (f *scenarioFixture) pollOnce() {
398
+ f.t.Helper()
399
+ batch, err := f.tasks.Poll(context.Background())
400
+ if err != nil {
401
+ f.t.Fatal(err)
402
+ }
403
+ handleBatch(f.manager)(context.Background(), f.repo, batch)
404
+ }
405
+
406
+ func (f *scenarioFixture) projection() runsvc.Run {
407
+ f.t.Helper()
408
+ r, err := f.engine.GetRun(context.Background(), f.runID)
409
+ if err != nil {
410
+ f.t.Fatal(err)
411
+ }
412
+ return r
413
+ }
414
+
415
+ func (f *scenarioFixture) waitNode(node string) {
416
+ f.t.Helper()
417
+ waitScenario(f.t, 10*time.Second, func() bool {
418
+ r, err := f.engine.GetRun(context.Background(), f.runID)
419
+ return err == nil && r.CurrentNode == node && r.CurrentNodeVisitID != ""
420
+ })
421
+ }
422
+
423
+ func (f *scenarioFixture) waitNodeWithNewVisit(node string, old runsvc.NodeVisitID) {
424
+ f.t.Helper()
425
+ waitScenario(f.t, 10*time.Second, func() bool {
426
+ r, err := f.engine.GetRun(context.Background(), f.runID)
427
+ return err == nil && r.CurrentNode == node && r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != old
428
+ })
429
+ }
430
+
431
+ func (f *scenarioFixture) firstVisit(node string) runsvc.NodeVisitID {
432
+ f.t.Helper()
433
+ r := f.projection()
434
+ if r.CurrentNode != node {
435
+ f.t.Fatalf("current node = %q, want %q", r.CurrentNode, node)
436
+ }
437
+ return r.CurrentNodeVisitID
438
+ }
439
+
440
+ func (f *scenarioFixture) submit(status workflow.Outcome, next string) {
441
+ f.t.Helper()
442
+ current := f.projection().CurrentNode
443
+ f.submitWithoutWaiting(status, next)
444
+ if next == "end" {
445
+ return
446
+ }
447
+ waitScenario(f.t, 10*time.Second, func() bool {
448
+ r, err := f.engine.GetRun(context.Background(), f.runID)
449
+ return err == nil && r.CurrentNode == next && r.CurrentNode != current
450
+ })
451
+ }
452
+
453
+ func (f *scenarioFixture) submitWithoutWaiting(status workflow.Outcome, next string) {
454
+ f.t.Helper()
455
+ r := f.projection()
456
+ ack, err := f.engine.SubmitReport(context.Background(), runsvc.ReportRequest{
457
+ RunID: f.runID, Node: r.CurrentNode,
458
+ ReportID: string(r.CurrentNodeVisitID) + ":scenario", Report: scenarioReport(status, next),
459
+ })
460
+ if err != nil || !ack.Accepted {
461
+ f.t.Fatalf("submit %s -> %s: ack=%+v err=%v", r.CurrentNode, next, ack, err)
462
+ }
463
+ // A successful ack is the public guarantee that report+route persistence
464
+ // happened. Recording it here lets the cross-boundary ordering assertion
465
+ // compare that observable ack with subsequent adapter effects.
466
+ f.log.add("report-persisted:" + r.CurrentNode)
467
+ }
468
+
469
+ func (f *scenarioFixture) waitCompleted() {
470
+ f.t.Helper()
471
+ waitScenario(f.t, 15*time.Second, func() bool {
472
+ r, err := f.engine.GetRun(context.Background(), f.runID)
473
+ return err == nil && r.State == runsvc.StateCompleted
474
+ })
475
+ }
476
+
477
+ func (f *scenarioFixture) waitEvent(event string) {
478
+ f.t.Helper()
479
+ waitScenario(f.t, 10*time.Second, func() bool { return f.log.count(event) > 0 })
480
+ }
481
+
482
+ func (f *scenarioFixture) restart() {
483
+ f.t.Helper()
484
+ shutdownScenarioEngine(f.engine)
485
+ f.engine = f.openEngine()
486
+ f.manager.Executor = f.engine
487
+ f.manager.Runs = f.engine
488
+ }
489
+
490
+ func shutdownScenarioEngine(e *goworkflows.Engine) {
491
+ if e == nil {
492
+ return
493
+ }
494
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
495
+ defer cancel()
496
+ _ = e.Shutdown(ctx)
497
+ }
498
+
499
+ func finishScenarioRun(t *testing.T, engine *goworkflows.Engine, id runsvc.ID) {
500
+ t.Helper()
501
+ for _, next := range []string{"verify", "pr-review", "end"} {
502
+ var r runsvc.Run
503
+ waitScenario(t, 10*time.Second, func() bool {
504
+ var err error
505
+ r, err = engine.GetRun(context.Background(), id)
506
+ return err == nil && r.CurrentNodeVisitID != "" && r.State == runsvc.StateWaiting
507
+ })
508
+ ack, err := engine.SubmitReport(context.Background(), runsvc.ReportRequest{
509
+ RunID: id, Node: r.CurrentNode,
510
+ ReportID: string(r.CurrentNodeVisitID) + ":finish", Report: scenarioReport(workflow.OutcomeSuccess, next),
511
+ })
512
+ if err != nil || !ack.Accepted {
513
+ t.Fatalf("finish %s -> %s: ack=%+v err=%v", r.CurrentNode, next, ack, err)
514
+ }
515
+ if next != "end" {
516
+ want := next
517
+ waitScenario(t, 10*time.Second, func() bool {
518
+ n, err := engine.GetRun(context.Background(), id)
519
+ return err == nil && n.CurrentNode == want
520
+ })
521
+ }
522
+ }
523
+ waitScenario(t, 15*time.Second, func() bool {
524
+ r, err := engine.GetRun(context.Background(), id)
525
+ return err == nil && r.State == runsvc.StateCompleted
526
+ })
527
+ }
528
+
529
+ func scenarioReport(status workflow.Outcome, next string) workflow.Report {
530
+ none := "None"
531
+ r := workflow.Report{
532
+ Status: status, NextStep: next,
533
+ Summary: workflow.Summary{
534
+ Completed: "work completed", Commits: "abc123", NotCompleted: none, IssuesDiscovered: none,
535
+ Verification: "checks passed", Notes: none,
536
+ },
537
+ Feedback: workflow.Feedback{
538
+ ReasonForNextStep: "continue", RequiredActions: "process this mailbox",
539
+ RelevantContext: "scenario context", ExpectedResult: "successful next visit",
540
+ },
541
+ }
542
+ if next == "end" {
543
+ r.Feedback = workflow.Feedback{
544
+ ReasonForNextStep: none, RequiredActions: none, RelevantContext: none, ExpectedResult: none,
545
+ }
546
+ }
547
+ return r
548
+ }
549
+
550
+ type scenarioLog struct {
551
+ mu sync.Mutex
552
+ events []string
553
+ }
554
+
555
+ type scenarioExecutor struct {
556
+ inner runsvc.Executor
557
+ log *scenarioLog
558
+ }
559
+
560
+ func (e scenarioExecutor) EnsureRun(ctx context.Context, start runsvc.Start) (bool, error) {
561
+ created, err := e.inner.EnsureRun(ctx, start)
562
+ if err == nil && created {
563
+ e.log.add("run-created:" + string(start.ID))
564
+ }
565
+ return created, err
566
+ }
567
+
568
+ func (e scenarioExecutor) SubmitReport(ctx context.Context, report runsvc.ReportRequest) (runsvc.ReportAck, error) {
569
+ return e.inner.SubmitReport(ctx, report)
570
+ }
571
+
572
+ func (e scenarioExecutor) CancelRun(ctx context.Context, id runsvc.ID, reason string) error {
573
+ return e.inner.CancelRun(ctx, id, reason)
574
+ }
575
+
576
+ func newScenarioLog() *scenarioLog { return &scenarioLog{} }
577
+
578
+ func (l *scenarioLog) add(event string) {
579
+ l.mu.Lock()
580
+ l.events = append(l.events, event)
581
+ l.mu.Unlock()
582
+ }
583
+
584
+ func (l *scenarioLog) all() []string {
585
+ l.mu.Lock()
586
+ defer l.mu.Unlock()
587
+ return append([]string(nil), l.events...)
588
+ }
589
+
590
+ func (l *scenarioLog) count(event string) int {
591
+ n := 0
592
+ for _, got := range l.all() {
593
+ if got == event {
594
+ n++
595
+ }
596
+ }
597
+ return n
598
+ }
599
+
600
+ func (l *scenarioLog) countPrefix(prefix string) int {
601
+ n := 0
602
+ for _, got := range l.all() {
603
+ if strings.HasPrefix(got, prefix) {
604
+ n++
605
+ }
606
+ }
607
+ return n
608
+ }
609
+
610
+ type scenarioComment struct {
611
+ node string
612
+ kind string
613
+ body string
614
+ }
615
+
616
+ type scenarioTaskSystem struct {
617
+ log *scenarioLog
618
+ mu sync.Mutex
619
+
620
+ claimed bool
621
+ mailboxes map[string]task.Mailbox
622
+ specs map[string]task.MailboxSpec
623
+ mailboxStatus map[string]string
624
+ parentStatus string
625
+ comments map[string]scenarioComment
626
+ transitions []string
627
+ creates map[string]int
628
+ completions map[string]int
629
+ completeFailures int
630
+ }
631
+
632
+ func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
633
+ return &scenarioTaskSystem{
634
+ log: log, mailboxes: map[string]task.Mailbox{}, specs: map[string]task.MailboxSpec{},
635
+ mailboxStatus: map[string]string{}, comments: map[string]scenarioComment{},
636
+ creates: map[string]int{}, completions: map[string]int{},
637
+ }
638
+ }
639
+
640
+ func (s *scenarioTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
641
+ s.log.add("poll")
642
+ s.mu.Lock()
643
+ defer s.mu.Unlock()
644
+ if s.claimed {
645
+ return nil, nil
646
+ }
647
+ return []task.Ticket{{ID: "ticket-1", Key: scenarioTicket, Title: "Scenario ticket"}}, nil
648
+ }
649
+
650
+ func (s *scenarioTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
651
+ return func(t task.Ticket) bool { return t.Key == scenarioTicket }, nil
652
+ }
653
+
654
+ func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workflowName string) error {
655
+ s.log.add("claim:" + ref.Key + ":" + workflowName)
656
+ s.mu.Lock()
657
+ s.claimed = true
658
+ s.mu.Unlock()
659
+ return nil
660
+ }
661
+
662
+ func (s *scenarioTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
663
+ return nil
664
+ }
665
+
666
+ func (s *scenarioTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
667
+ s.log.add("mailboxes:ensure")
668
+ s.mu.Lock()
669
+ defer s.mu.Unlock()
670
+ out := map[string]task.Mailbox{}
671
+ for _, spec := range specs {
672
+ s.specs[spec.Node] = spec
673
+ mb, ok := s.mailboxes[spec.Node]
674
+ if !ok {
675
+ mb = task.Mailbox{ID: "mb-" + spec.Node, Key: spec.Title, Node: spec.Node}
676
+ s.mailboxes[spec.Node] = mb
677
+ s.mailboxStatus[spec.Node] = "To Do"
678
+ s.creates[spec.Node]++
679
+ s.log.add("mailbox-created:" + spec.Title)
680
+ }
681
+ out[spec.Node] = mb
682
+ }
683
+ return out, nil
684
+ }
685
+
686
+ func (s *scenarioTaskSystem) StartDefaults() config.RawValues {
687
+ return config.RawValues{"transitionTo": map[string]any{"parentStatus": "In Progress"}}
688
+ }
689
+
690
+ func (s *scenarioTaskSystem) WorkDefaults() config.RawValues {
691
+ return config.RawValues{"transitionTo": map[string]any{"taskStatus": "In Progress"}}
692
+ }
693
+
694
+ func (s *scenarioTaskSystem) EndDefaults() config.RawValues {
695
+ return config.RawValues{"transitionTo": map[string]any{"parentStatus": "Done"}}
696
+ }
697
+
698
+ func (s *scenarioTaskSystem) ApplyTaskConfig(_ context.Context, target task.Target, cfg config.RawValues) error {
699
+ transition, _ := cfg["transitionTo"].(map[string]any)
700
+ s.mu.Lock()
701
+ defer s.mu.Unlock()
702
+ if target.Mailbox == nil {
703
+ status, _ := transition["parentStatus"].(string)
704
+ s.parentStatus = status
705
+ call := "parent:" + status
706
+ s.transitions = append(s.transitions, call)
707
+ s.log.add("transition:" + call)
708
+ return nil
709
+ }
710
+ status, _ := transition["taskStatus"].(string)
711
+ s.mailboxStatus[target.Mailbox.Node] = status
712
+ call := target.Mailbox.Node + ":" + status
713
+ s.transitions = append(s.transitions, call)
714
+ s.log.add("transition:" + call)
715
+ return nil
716
+ }
717
+
718
+ func (s *scenarioTaskSystem) CompleteMailbox(_ context.Context, mailbox task.Mailbox) error {
719
+ s.mu.Lock()
720
+ if s.completeFailures > 0 {
721
+ s.completeFailures--
722
+ s.mu.Unlock()
723
+ s.log.add("complete-failed:" + mailbox.Key)
724
+ return errors.New("temporary completion failure")
725
+ }
726
+ s.mailboxStatus[mailbox.Node] = "Done"
727
+ s.completions[mailbox.Node]++
728
+ s.mu.Unlock()
729
+ s.log.add("complete:" + mailbox.Key)
730
+ return nil
731
+ }
732
+
733
+ func (s *scenarioTaskSystem) HasComment(_ context.Context, _ task.Target, marker string) (bool, error) {
734
+ s.mu.Lock()
735
+ defer s.mu.Unlock()
736
+ _, ok := s.comments[marker]
737
+ return ok, nil
738
+ }
739
+
740
+ func (s *scenarioTaskSystem) Comment(_ context.Context, target task.Target, body, marker string) error {
741
+ s.mu.Lock()
742
+ if _, exists := s.comments[marker]; exists {
743
+ s.mu.Unlock()
744
+ s.log.add("comment-existing:" + marker)
745
+ return nil
746
+ }
747
+ node := "parent"
748
+ if target.Mailbox != nil {
749
+ node = target.Mailbox.Node
750
+ }
751
+ kind := marker[strings.LastIndex(marker, ":")+1:]
752
+ s.comments[marker] = scenarioComment{node: node, kind: kind, body: body}
753
+ s.mu.Unlock()
754
+ s.log.add("comment:" + scenarioTicket + ":" + node + ":" + kind)
755
+ return nil
756
+ }
757
+
758
+ func (s *scenarioTaskSystem) ResetForRecovery(context.Context, task.TicketRef, []task.Mailbox, config.RawValues) error {
759
+ return nil
760
+ }
761
+
762
+ func (s *scenarioTaskSystem) setCompleteFailures(n int) {
763
+ s.mu.Lock()
764
+ s.completeFailures = n
765
+ s.mu.Unlock()
766
+ }
767
+
768
+ func (s *scenarioTaskSystem) mailboxCreateCount(node string) int {
769
+ s.mu.Lock()
770
+ defer s.mu.Unlock()
771
+ return s.creates[node]
772
+ }
773
+
774
+ func (s *scenarioTaskSystem) totalMailboxCreates() int {
775
+ s.mu.Lock()
776
+ defer s.mu.Unlock()
777
+ n := 0
778
+ for _, count := range s.creates {
779
+ n += count
780
+ }
781
+ return n
782
+ }
783
+
784
+ func (s *scenarioTaskSystem) commentCount(node, kind string) int {
785
+ s.mu.Lock()
786
+ defer s.mu.Unlock()
787
+ n := 0
788
+ for _, comment := range s.comments {
789
+ if comment.node == node && comment.kind == kind {
790
+ n++
791
+ }
792
+ }
793
+ return n
794
+ }
795
+
796
+ func (s *scenarioTaskSystem) totalComments() int {
797
+ s.mu.Lock()
798
+ defer s.mu.Unlock()
799
+ return len(s.comments)
800
+ }
801
+
802
+ func (s *scenarioTaskSystem) completeCount(node string) int {
803
+ s.mu.Lock()
804
+ defer s.mu.Unlock()
805
+ return s.completions[node]
806
+ }
807
+
808
+ func (s *scenarioTaskSystem) transitionCalls() []string {
809
+ s.mu.Lock()
810
+ defer s.mu.Unlock()
811
+ return append([]string(nil), s.transitions...)
812
+ }
813
+
814
+ type scenarioTerminal struct {
815
+ terminal runner.Terminal
816
+ live bool
817
+ }
818
+
819
+ type scenarioRunner struct {
820
+ log *scenarioLog
821
+ mu sync.Mutex
822
+
823
+ environments map[string]runner.Environment
824
+ terminals map[string]*scenarioTerminal
825
+ commands map[string][]runner.Command
826
+ launches map[string]int
827
+ cleanups int
828
+ }
829
+
830
+ func newScenarioRunner(log *scenarioLog) *scenarioRunner {
831
+ return &scenarioRunner{
832
+ log: log, environments: map[string]runner.Environment{}, terminals: map[string]*scenarioTerminal{},
833
+ commands: map[string][]runner.Command{}, launches: map[string]int{},
834
+ }
835
+ }
836
+
837
+ func (r *scenarioRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
838
+ return []runner.RepoCandidate{{Name: scenarioRepo, Path: scenarioRepoPath}}, nil
839
+ }
840
+
841
+ func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error { return nil }
842
+
843
+ func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
844
+ r.mu.Lock()
845
+ defer r.mu.Unlock()
846
+ if env, ok := r.environments[string(spec.RunID)]; ok {
847
+ return env, nil
848
+ }
849
+ env := runner.Environment{ID: "env-" + string(spec.RunID), Path: scenarioRepoPath}
850
+ r.environments[string(spec.RunID)] = env
851
+ r.log.add("environment:" + string(spec.RunID))
852
+ return env, nil
853
+ }
854
+
855
+ func (r *scenarioRunner) FindTerminal(_ context.Context, _ runner.Environment, title string) (runner.Terminal, bool, error) {
856
+ r.mu.Lock()
857
+ defer r.mu.Unlock()
858
+ t, ok := r.terminals[title]
859
+ if !ok || !t.live {
860
+ return runner.Terminal{}, false, nil
861
+ }
862
+ return t.terminal, true, nil
863
+ }
864
+ func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
865
+ r.mu.Lock()
866
+ defer r.mu.Unlock()
867
+ for _, current := range r.terminals {
868
+ if current.terminal.ID == terminal.ID && current.live {
869
+ return current.terminal, true, nil
870
+ }
871
+ }
872
+ return runner.Terminal{}, false, nil
873
+ }
874
+ func (r *scenarioRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
875
+ func (r *scenarioRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
876
+ return r.EnsureTerminal(ctx, env, title, command)
877
+ }
878
+
879
+ func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
880
+ r.mu.Lock()
881
+ defer r.mu.Unlock()
882
+ if t, ok := r.terminals[terminal.Title]; ok {
883
+ t.live = false
884
+ }
885
+ r.log.add("terminal-closed:" + terminal.Title)
886
+ return nil
887
+ }
888
+
889
+ func (r *scenarioRunner) EnsureTerminal(_ context.Context, _ runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
890
+ r.mu.Lock()
891
+ defer r.mu.Unlock()
892
+ if existing, ok := r.terminals[title]; ok && existing.live {
893
+ return existing.terminal, nil
894
+ }
895
+ terminal := runner.Terminal{ID: "terminal-" + title, Title: title}
896
+ r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
897
+ r.commands[title] = append(r.commands[title], command)
898
+ r.launches[title]++
899
+ r.log.add("terminal-created:" + title)
900
+ return terminal, nil
901
+ }
902
+
903
+ func (r *scenarioRunner) CloseTerminals(context.Context, runner.RunSpec) error {
904
+ r.mu.Lock()
905
+ defer r.mu.Unlock()
906
+ for _, terminal := range r.terminals {
907
+ terminal.live = false
908
+ }
909
+ return nil
910
+ }
911
+
912
+ func (r *scenarioRunner) CleanupRun(context.Context, runner.RunSpec) error {
913
+ r.mu.Lock()
914
+ r.cleanups++
915
+ r.terminals = map[string]*scenarioTerminal{}
916
+ r.mu.Unlock()
917
+ r.log.add("runner-cleanup")
918
+ return nil
919
+ }
920
+
921
+ func (r *scenarioRunner) launchCount(title string) int {
922
+ r.mu.Lock()
923
+ defer r.mu.Unlock()
924
+ return r.launches[title]
925
+ }
926
+
927
+ func (r *scenarioRunner) command(title string) runner.Command {
928
+ r.mu.Lock()
929
+ defer r.mu.Unlock()
930
+ commands := r.commands[title]
931
+ if len(commands) == 0 {
932
+ return runner.Command{}
933
+ }
934
+ return commands[len(commands)-1]
935
+ }
936
+
937
+ func (r *scenarioRunner) cleanupCount() int {
938
+ r.mu.Lock()
939
+ defer r.mu.Unlock()
940
+ return r.cleanups
941
+ }
942
+
943
+ type scenarioHarness struct {
944
+ log *scenarioLog
945
+ mu sync.Mutex
946
+
947
+ sessions map[string]harness.Session
948
+ launches map[string][]harness.LaunchSpec
949
+ }
950
+
951
+ func newScenarioHarness(log *scenarioLog) *scenarioHarness {
952
+ return &scenarioHarness{log: log, sessions: map[string]harness.Session{}, launches: map[string][]harness.LaunchSpec{}}
953
+ }
954
+
955
+ func (h *scenarioHarness) ValidateAgent(context.Context, string, string) error { return nil }
956
+
957
+ func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string) (harness.Session, bool, error) {
958
+ h.mu.Lock()
959
+ defer h.mu.Unlock()
960
+ session, ok := h.sessions[title]
961
+ return session, ok, nil
962
+ }
963
+
964
+ func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
965
+ h.mu.Lock()
966
+ h.launches[spec.Node] = append(h.launches[spec.Node], spec)
967
+ h.sessions[spec.Title] = harness.Session{ID: "session-" + spec.Title, Title: spec.Title}
968
+ h.mu.Unlock()
969
+ h.log.add("harness-launched:" + spec.Title)
970
+ return runner.Command{
971
+ Executable: "fake-harness",
972
+ Env: map[string]string{
973
+ "RELAY_FLOW_RUN_ID": string(spec.RunID),
974
+ "RELAY_FLOW_WORKFLOW": spec.Workflow,
975
+ "RELAY_FLOW_REPO": spec.RepoName,
976
+ "RELAY_FLOW_TICKET": spec.Ticket,
977
+ "RELAY_FLOW_NODE": spec.Node,
978
+ "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
979
+ "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
980
+ "RELAY_FLOW_NEXT_STEPS_JSON": routesJSON(spec.NextSteps),
981
+ },
982
+ }, nil
983
+ }
984
+
985
+ func (h *scenarioHarness) launch(node string) harness.LaunchSpec {
986
+ h.mu.Lock()
987
+ defer h.mu.Unlock()
988
+ launches := h.launches[node]
989
+ if len(launches) == 0 {
990
+ return harness.LaunchSpec{}
991
+ }
992
+ return launches[len(launches)-1]
993
+ }
994
+
995
+ func routesJSON(routes []workflow.Route) string {
996
+ parts := make([]string, 0, len(routes))
997
+ for _, route := range routes {
998
+ parts = append(parts, route.Target)
999
+ }
1000
+ sort.Strings(parts)
1001
+ return "[\"" + strings.Join(parts, "\",\"") + "\"]"
1002
+ }
1003
+
1004
+ type scenarioRepoLookup struct{ reg *repo.Registry }
1005
+
1006
+ func (r scenarioRepoLookup) Exists(name string) bool {
1007
+ _, ok := r.reg.Get(name)
1008
+ return ok
1009
+ }
1010
+
1011
+ func waitScenario(t *testing.T, timeout time.Duration, condition func() bool) {
1012
+ t.Helper()
1013
+ deadline := time.Now().Add(timeout)
1014
+ for time.Now().Before(deadline) {
1015
+ if condition() {
1016
+ return
1017
+ }
1018
+ time.Sleep(10 * time.Millisecond)
1019
+ }
1020
+ t.Fatal("condition not met within " + timeout.String())
1021
+ }
1022
+
1023
+ func eventIndex(events []string, want string) int {
1024
+ for i, event := range events {
1025
+ if event == want {
1026
+ return i
1027
+ }
1028
+ }
1029
+ return -1
1030
+ }
1031
+
1032
+ func assertBefore(t *testing.T, events []string, first, second string) {
1033
+ t.Helper()
1034
+ a, b := eventIndex(events, first), eventIndex(events, second)
1035
+ if a < 0 || b < 0 || a >= b {
1036
+ t.Fatalf("want %q before %q; events=%v", first, second, events)
1037
+ }
1038
+ }
1039
+
1040
+ func assertTransitionOrder(t *testing.T, events []string, current, next string) {
1041
+ t.Helper()
1042
+ order := []string{
1043
+ "report-persisted:" + current,
1044
+ "comment:TEST-1:" + current + ":summary",
1045
+ "comment:TEST-1:" + next + ":feedback",
1046
+ "complete:TEST-1:" + current,
1047
+ "transition:" + next + ":In Progress",
1048
+ "terminal-created:TEST-1:" + next,
1049
+ }
1050
+ for i := 0; i+1 < len(order); i++ {
1051
+ assertBefore(t, events, order[i], order[i+1])
1052
+ }
1053
+ }
1054
+
1055
+ func assertMailboxDefinitions(t *testing.T, tasks *scenarioTaskSystem) {
1056
+ t.Helper()
1057
+ tasks.mu.Lock()
1058
+ defer tasks.mu.Unlock()
1059
+ for node, description := range map[string]string{
1060
+ "implement": "Implement the requested change.",
1061
+ "verify": "Verify the implementation.",
1062
+ "pr-review": "Review and approve the pull request.",
1063
+ } {
1064
+ spec, ok := tasks.specs[node]
1065
+ if !ok {
1066
+ t.Fatalf("mailbox spec %q missing", node)
1067
+ }
1068
+ if spec.Title != "TEST-1:"+node || !strings.Contains(spec.Description, description) {
1069
+ t.Fatalf("mailbox %q = %+v, want stable title and node description", node, spec)
1070
+ }
1071
+ }
1072
+ if len(tasks.specs) != 3 {
1073
+ t.Fatalf("mailbox specs = %d, want 3 (no start/end mailbox)", len(tasks.specs))
1074
+ }
1075
+ }
1076
+
1077
+ func assertLaunch(t *testing.T, f *scenarioFixture, node string, nodeType workflow.NodeType) {
1078
+ t.Helper()
1079
+ title := "TEST-1:" + node
1080
+ launch := f.harness.launch(node)
1081
+ if launch.Title != title || launch.NodeType != nodeType {
1082
+ t.Fatalf("launch %q = %+v", node, launch)
1083
+ }
1084
+ cmd := f.runner.command(title)
1085
+ for _, key := range []string{
1086
+ "RELAY_FLOW_RUN_ID", "RELAY_FLOW_WORKFLOW", "RELAY_FLOW_REPO",
1087
+ "RELAY_FLOW_TICKET", "RELAY_FLOW_NODE", "RELAY_FLOW_NODE_TYPE", "RELAY_FLOW_NEXT_STEPS_JSON",
1088
+ } {
1089
+ if cmd.Env[key] == "" {
1090
+ t.Errorf("%s missing from %s launch env", key, node)
1091
+ }
1092
+ }
1093
+ if _, ok := cmd.Env["RELAY_FLOW_NUDGE_PROMPT"]; !ok {
1094
+ t.Errorf("RELAY_FLOW_NUDGE_PROMPT missing from %s launch env", node)
1095
+ }
1096
+ }
1097
+
1098
+ func assertExactHappyEffects(t *testing.T, f *scenarioFixture) {
1099
+ t.Helper()
1100
+ if got := f.tasks.totalMailboxCreates(); got != 3 {
1101
+ t.Fatalf("mailbox creates = %d, want 3", got)
1102
+ }
1103
+ if got := f.tasks.totalComments(); got != 5 {
1104
+ t.Fatalf("comments = %d, want 5", got)
1105
+ }
1106
+ for _, node := range []string{"implement", "verify", "pr-review"} {
1107
+ if got := f.tasks.commentCount(node, "summary"); got != 1 {
1108
+ t.Errorf("%s summaries = %d, want 1", node, got)
1109
+ }
1110
+ if got := f.tasks.completeCount(node); got != 1 {
1111
+ t.Errorf("%s completions = %d, want 1", node, got)
1112
+ }
1113
+ }
1114
+ if got := f.tasks.commentCount("verify", "feedback"); got != 1 {
1115
+ t.Errorf("verify feedback = %d, want 1", got)
1116
+ }
1117
+ if got := f.tasks.commentCount("pr-review", "feedback"); got != 1 {
1118
+ t.Errorf("pr-review feedback = %d, want 1", got)
1119
+ }
1120
+ if got := f.tasks.commentCount("parent", "feedback"); got != 0 {
1121
+ t.Errorf("feedback written for end = %d", got)
1122
+ }
1123
+ assertNoDuplicateTransitionCalls(t, f.tasks.transitionCalls())
1124
+ }
1125
+
1126
+ func assertNoDuplicateTransitionCalls(t *testing.T, calls []string) {
1127
+ t.Helper()
1128
+ want := []string{
1129
+ "parent:In Progress", "implement:In Progress", "verify:In Progress",
1130
+ "pr-review:In Progress", "parent:Done",
1131
+ }
1132
+ if strings.Join(calls, "|") != strings.Join(want, "|") {
1133
+ t.Fatalf("transition calls = %v, want exactly %v", calls, want)
1134
+ }
1135
+ }