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