relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -49,6 +49,24 @@ func TestMergeMapsRecursively(t *testing.T) {
49
49
  }
50
50
  }
51
51
 
52
+ func TestMergeRawValuesMapsRecursively(t *testing.T) {
53
+ defaults := config.RawValues{"templates": map[string]any{
54
+ "mailboxDescription": "default mailbox",
55
+ "summaryComment": "default summary",
56
+ }}
57
+ override := config.RawValues{"templates": config.RawValues{
58
+ "mailboxDescription": "custom mailbox",
59
+ }}
60
+ got := config.Merge(defaults, override)
61
+ templates, ok := got["templates"].(map[string]any)
62
+ if !ok {
63
+ t.Fatalf("templates = %#v", got["templates"])
64
+ }
65
+ if templates["mailboxDescription"] != "custom mailbox" || templates["summaryComment"] != "default summary" {
66
+ t.Fatalf("templates = %#v", templates)
67
+ }
68
+ }
69
+
52
70
  func TestMergeListReplaces(t *testing.T) {
53
71
  root := config.RawValues{"labels": []any{"a", "b"}}
54
72
  wf := config.RawValues{"labels": []any{"c"}}
@@ -2,7 +2,6 @@ package goworkflows
2
2
 
3
3
  import (
4
4
  "context"
5
- "errors"
6
5
  "fmt"
7
6
  "log/slog"
8
7
  "sort"
@@ -21,10 +20,11 @@ import (
21
20
  // Activities holds the replaceable dependencies shared by every durable
22
21
  // activity. One Activities value is registered with the activity worker.
23
22
  type Activities struct {
24
- Repos *repo.Registry
25
- Runner runner.Runner
26
- Harness harness.Harness
27
- Runs *RunProjection
23
+ Repos *repo.Registry
24
+ Runner runner.Runner
25
+ Harness harness.Harness
26
+ TaskSystem string
27
+ Runs *RunProjection
28
28
  }
29
29
 
30
30
  func (a *Activities) taskSystem(repoName string) (task.System, error) {
@@ -51,6 +51,16 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
51
51
  if err != nil {
52
52
  return nil, err
53
53
  }
54
+ for i := range specs {
55
+ data := specs[i].TextData
56
+ data.RunID = string(w.RunID)
57
+ data.Repo = w.Repo
58
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
59
+ if err != nil {
60
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
61
+ }
62
+ specs[i].Description = appendText(custom, specs[i].Description)
63
+ }
54
64
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
55
65
  }
56
66
 
@@ -97,13 +107,23 @@ func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath
97
107
  return a.Runner.EnsureEnvironment(ctx, spec)
98
108
  }
99
109
 
110
+ func (a *Activities) SetEnvironmentStatus(ctx context.Context, w run.Work, repoPath, status string) error {
111
+ spec := a.runSpec(w)
112
+ spec.RepoPath = repoPath
113
+ env, err := a.Runner.EnsureEnvironment(ctx, spec)
114
+ if err != nil {
115
+ return err
116
+ }
117
+ return a.Runner.SetEnvironmentStatus(ctx, env, status)
118
+ }
119
+
100
120
  func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
101
121
  return a.Runs.loadNodeRuntime(ctx, id, node)
102
122
  }
103
123
 
104
124
  // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
105
- // path. A live terminal is rebound to the new visit; otherwise a fresh
106
- // terminal is created and its direct IDs atomically replace the old binding.
125
+ // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
126
+ // creates a replacement and its direct ID is persisted immediately.
107
127
  func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
108
128
  a.Runs.runtimeMu.Lock()
109
129
  defer a.Runs.runtimeMu.Unlock()
@@ -124,81 +144,78 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
124
144
  if err != nil {
125
145
  return err
126
146
  }
127
- hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
147
+ rs := a.runSpec(nw.Work)
148
+ rs.RepoPath = repoPath
149
+ env, err := a.Runner.EnsureEnvironment(ctx, rs)
150
+ if err != nil {
151
+ return err
152
+ }
153
+ status := runner.WorkspaceStatusInProgress
154
+ if spec.NodeType == workflow.NodeHITL {
155
+ status = runner.WorkspaceStatusInReview
156
+ }
157
+ if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
158
+ return err
159
+ }
128
160
  // IDs come from the guarded current row; the activity input's prior visit
129
161
  // is used only to decide whether a live process needs rebinding.
130
162
  rt.TerminalID = currentRuntime.TerminalID
131
163
  rt.SessionID = currentRuntime.SessionID
132
- freshSession := false
133
- if rt.TerminalID != "" {
134
- terminal := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
135
- _, ok, inspectErr := a.Runner.InspectTerminal(ctx, terminal)
136
- if inspectErr != nil {
137
- return inspectErr
164
+ spec.ResumeID = rt.SessionID
165
+ stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
166
+ terminal, live, err := a.Runner.FindTerminal(ctx, stored)
167
+ if err != nil {
168
+ return err
169
+ }
170
+ if live {
171
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
172
+ terminal.ID, rt.SessionID, rt.SessionID); err != nil {
173
+ return err
138
174
  }
139
- if ok {
140
- // Same-visit retry/restart leaves the running turn untouched. A
141
- // revisit sends only the new work prompt to the retained session.
142
- if !revisit {
143
- return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
144
- rt.TerminalID, rt.SessionID, rt.SessionID)
145
- }
146
- prompt := followUpPrompt(nw.Mailbox.Key)
147
- if spec.NudgePrompt != "" {
148
- prompt += "\n\n" + spec.NudgePrompt
149
- }
150
- if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
151
- return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
152
- rt.TerminalID, rt.SessionID, rt.SessionID)
153
- }
154
- // Direct use failed. Close the known live terminal before replacing
155
- // it so a second agent process cannot be left running.
156
- if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
157
- return err
158
- }
159
- freshSession = true
175
+ if !revisit {
176
+ // Same-visit retry/restart is silent: do not render, build, or send.
177
+ return nil
178
+ }
179
+ prompt, err := a.Harness.RenderPrompt(harness.PromptFeedback, spec.PromptData, spec.NudgePrompt)
180
+ if err != nil {
181
+ return err
182
+ }
183
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
184
+ return nil
185
+ }
186
+ // Direct use failed. Close the known live terminal before replacing it
187
+ // so a second agent process cannot be left running.
188
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
189
+ return err
160
190
  }
161
191
  }
162
192
 
163
- if rt.SessionID != "" && !freshSession {
164
- spec.ResumeID = rt.SessionID
193
+ // An initial or replacement terminal resumes the stored session and
194
+ // receives the rendered initial prompt. Same-visit replacements omit the
195
+ // node nudge; a new visit includes it.
196
+ nudge := ""
197
+ if rt.NodeVisitID == "" || revisit {
198
+ nudge = spec.NudgePrompt
165
199
  }
166
- // Custom instructions belong to a node entry, not same-visit recovery.
167
- if !hadRuntime || revisit {
168
- spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
169
- }
170
- cmd, err := a.Harness.BuildCommand(spec)
200
+ spec.Prompt, err = a.Harness.RenderPrompt(harness.PromptInitial, spec.PromptData, nudge)
171
201
  if err != nil {
172
202
  return err
173
203
  }
174
- rs := a.runSpec(nw.Work)
175
- rs.RepoPath = repoPath
176
- env, err := a.Runner.EnsureEnvironment(ctx, rs)
204
+ cmd, err := a.Harness.BuildCommand(spec)
177
205
  if err != nil {
178
206
  return err
179
207
  }
180
- terminal, err := a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
181
- sessionID := rt.SessionID
182
- if errors.Is(err, runner.ErrSessionUnavailable) && rt.SessionID != "" {
183
- spec.ResumeID = ""
184
- cmd, buildErr := a.Harness.BuildCommand(spec)
185
- if buildErr != nil {
186
- return buildErr
187
- }
188
- terminal, err = a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
189
- sessionID = ""
190
- }
191
- if freshSession {
192
- sessionID = ""
193
- }
208
+ replacement, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
194
209
  if err != nil {
195
210
  return err
196
211
  }
212
+ // Persist a newly created/replacement handle before any later external
213
+ // effect. Runtime session registration may update SessionID independently.
197
214
  if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
198
- terminal.ID, rt.SessionID, sessionID); err != nil {
199
- // The visit changed after terminal creation. Close the terminal whose
200
- // binding was rejected so stale work cannot leak or report.
201
- _ = a.Runner.CloseTerminal(ctx, terminal)
215
+ replacement.ID, rt.SessionID, rt.SessionID); err != nil {
216
+ if replacement.ID != rt.TerminalID {
217
+ _ = a.Runner.CloseTerminal(ctx, replacement)
218
+ }
202
219
  return err
203
220
  }
204
221
  return nil
@@ -274,7 +291,14 @@ func (a *Activities) Comment(ctx context.Context, repoName string, cw run.Commen
274
291
  if err != nil {
275
292
  return err
276
293
  }
277
- if err := sys.Comment(ctx, cw.Item, cw.Body, cw.Marker); err != nil {
294
+ body := cw.Body
295
+ if cw.TextKind != "" {
296
+ body, err = sys.RenderText(cw.TextKind, cw.TextData)
297
+ if err != nil {
298
+ return fmt.Errorf("render %s: %w", cw.TextKind, err)
299
+ }
300
+ }
301
+ if err := sys.Comment(ctx, cw.Item, body, cw.Marker); err != nil {
278
302
  return err
279
303
  }
280
304
  // Log AFTER the write succeeds so the line is a true effect record.
@@ -393,19 +417,7 @@ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, statu
393
417
  // description, and every legal route with its when explanation.
394
418
  func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
395
419
  var b strings.Builder
396
- fmt.Fprintf(&b, "Parent Jira ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
397
- ticketKey, name, n.Type, n.Agent, n.Description)
398
- if n.Type == workflow.NodeHITL {
399
- b.WriteString(`
400
-
401
- 1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
402
- 2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.
403
- 3. Present the complete report through OpenCode's Question tool with exactly two options: Approve and Reject.
404
- 4. If approved, output the report verbatim. If rejected, return to step 1.`)
405
- }
406
- b.WriteString(`
407
-
408
- Required report format:
420
+ b.WriteString(`Required report format:
409
421
 
410
422
  STATUS: success | failure
411
423
  NEXT STEP: <one valid node name>
@@ -424,7 +436,9 @@ REQUIRED ACTIONS:
424
436
  RELEVANT CONTEXT:
425
437
  EXPECTED RESULT:
426
438
 
427
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None. NEXT STEP must name exactly one target listed below for your status. When NEXT STEP is end, every FEEDBACK field must be None.`)
439
+ Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
440
+
441
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
428
442
  writeRoutes := func(label string, routes []workflow.Route) {
429
443
  if len(routes) == 0 {
430
444
  return
@@ -440,11 +454,20 @@ Every field is required; use None for an intentionally empty section. COMMITS mu
440
454
  }
441
455
  writeRoutes("On success", n.OnSuccess)
442
456
  writeRoutes("On failure", n.OnFailure)
457
+ successRoutes := routesText(n.OnSuccess)
458
+ failureRoutes := routesText(n.OnFailure)
443
459
  return task.MailboxSpec{
444
460
  Node: name,
445
461
  Title: ticketKey + ":" + name,
446
462
  Description: b.String(),
447
463
  TaskConfig: n.TaskConfig,
464
+ TextData: task.TextData{
465
+ Ticket: ticketKey, Workflow: wf.Name, Node: name, NodeType: string(n.Type),
466
+ Agent: n.Agent, NodeDescription: n.Description,
467
+ NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
468
+ SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
469
+ Mailbox: ticketKey + ":" + name,
470
+ },
448
471
  }
449
472
  }
450
473
 
@@ -464,20 +487,46 @@ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
464
487
  return out
465
488
  }
466
489
 
467
- // BuildLaunchSpecPrompt points the agent to its parent and isolated mailbox.
468
- func BuildLaunchSpecPrompt(ticketKey, mailboxKey string) string {
469
- return fmt.Sprintf("Read parent Jira ticket %s for the original task context.\n\nYour Jira mailbox subtask is %s. Read only its description and comments for your node instructions and feedback.", ticketKey, mailboxKey)
490
+ // RenderMailboxSpecs asks the selected task system to render customizable
491
+ // mailbox text, then appends the fixed report contract and legal routes.
492
+ // It is shared by normal execution and explicit database-loss recovery.
493
+ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
494
+ specs := MailboxSpecs(wf, w.Parent.Key)
495
+ for i := range specs {
496
+ data := specs[i].TextData
497
+ data.RunID = string(w.RunID)
498
+ data.Repo = w.Repo
499
+ custom, err := sys.RenderText(task.TextMailboxDescription, data)
500
+ if err != nil {
501
+ return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
502
+ }
503
+ specs[i].Description = appendText(custom, specs[i].Description)
504
+ }
505
+ return specs, nil
470
506
  }
471
507
 
472
- func followUpPrompt(mailboxKey string) string {
473
- return fmt.Sprintf("New feedback was added to the comments section of your mailbox subtask %s. Read it.", mailboxKey)
508
+ func routesText(routes []workflow.Route) string {
509
+ var b strings.Builder
510
+ for i, route := range routes {
511
+ if i > 0 {
512
+ b.WriteString("\n")
513
+ }
514
+ b.WriteString(route.Target)
515
+ if route.When != "" {
516
+ b.WriteString(" — when: " + route.When)
517
+ }
518
+ }
519
+ return b.String()
474
520
  }
475
521
 
476
- func appendPrompt(prompt, extra string) string {
477
- if extra == "" {
478
- return prompt
522
+ func appendText(first, second string) string {
523
+ if first == "" {
524
+ return second
525
+ }
526
+ if second == "" {
527
+ return first
479
528
  }
480
- return prompt + "\n\n" + extra
529
+ return first + "\n\n" + second
481
530
  }
482
531
 
483
532
  // mergeTaskConfig overlays node task config onto workflow task config using
@@ -0,0 +1,124 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ )
15
+
16
+ func TestEndSelectionSkipsFeedbackCommentActivity(t *testing.T) {
17
+ log := newEventLog()
18
+ sys := newFakeTaskSystem(log)
19
+ engine := newEndFeedbackTestEngine(t, sys)
20
+ wf := workflow.Workflow{
21
+ Name: "endFeedback",
22
+ Repos: []string{"payments"},
23
+ Nodes: map[string]workflow.Node{
24
+ "start": {OnSuccess: []workflow.Route{{Target: "implement"}}},
25
+ "implement": {
26
+ Type: workflow.NodeAgent, Agent: "build", Description: "implement",
27
+ OnSuccess: []workflow.Route{{Target: "end"}},
28
+ OnFailure: []workflow.Route{{Target: "implement"}},
29
+ },
30
+ "end": {},
31
+ },
32
+ }
33
+ rid := identity.NewRunID("payments", wf.Name, "PAY-101")
34
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
35
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
36
+ Ticket: task.TicketRef{ID: "demo-parent", Key: "demo-parent", Title: "parent"},
37
+ }); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
41
+ return r.CurrentNode == "implement"
42
+ })
43
+
44
+ if _, err := engine.SubmitReport(context.Background(), run.ReportRequest{
45
+ RunID: rid, Node: "implement", ReportID: "end-feedback-test",
46
+ Report: endFeedbackSuccessReport(),
47
+ }); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
51
+ return r.State == run.StateCompleted
52
+ })
53
+
54
+ events := log.all()
55
+ if !hasEvent(events, "comment:demo-parent-implement") {
56
+ t.Fatalf("current summary comment missing: %v", events)
57
+ }
58
+ if countEvent(events, "comment:demo-parent-implement") != 1 {
59
+ t.Fatalf("summary comment count = %d; events=%v", countEvent(events, "comment:demo-parent-implement"), events)
60
+ }
61
+ if hasEventPrefixExcept(events, "comment:", "comment:demo-parent-implement") {
62
+ t.Fatalf("end selection wrote feedback comment; events=%v", events)
63
+ }
64
+ }
65
+
66
+ func endFeedbackSuccessReport() workflow.Report {
67
+ none := "None"
68
+ return workflow.Report{
69
+ Status: workflow.OutcomeSuccess, NextStep: "end",
70
+ Summary: workflow.Summary{
71
+ Completed: "done", Commits: "abc123", NotCompleted: none,
72
+ IssuesDiscovered: none, Verification: "tested", Notes: none,
73
+ },
74
+ Feedback: workflow.Feedback{
75
+ ReasonForNextStep: none, RequiredActions: none,
76
+ RelevantContext: none, ExpectedResult: none,
77
+ },
78
+ }
79
+ }
80
+
81
+ func newEndFeedbackTestEngine(t *testing.T, sys task.System) *goworkflows.Engine {
82
+ t.Helper()
83
+ return newEngine(t, goworkflows.Dependencies{
84
+ Repos: repoRegistryWith("payments", sys),
85
+ Runner: newFakeRunner(newEventLog()),
86
+ Harness: newFakeHarness(newEventLog()),
87
+ })
88
+ }
89
+
90
+ func waitForEndFeedbackTest(t *testing.T, engine *goworkflows.Engine, id run.ID, predicate func(run.Run) bool) {
91
+ t.Helper()
92
+ waitFor(t, 10*time.Second, func() bool {
93
+ r, err := engine.GetRun(context.Background(), id)
94
+ return err == nil && predicate(r)
95
+ })
96
+ }
97
+
98
+ func hasEvent(events []string, want string) bool {
99
+ for _, event := range events {
100
+ if event == want {
101
+ return true
102
+ }
103
+ }
104
+ return false
105
+ }
106
+
107
+ func countEvent(events []string, want string) int {
108
+ count := 0
109
+ for _, event := range events {
110
+ if event == want {
111
+ count++
112
+ }
113
+ }
114
+ return count
115
+ }
116
+
117
+ func hasEventPrefixExcept(events []string, prefix, allowed string) bool {
118
+ for _, event := range events {
119
+ if strings.HasPrefix(event, prefix) && event != allowed {
120
+ return true
121
+ }
122
+ }
123
+ return false
124
+ }
@@ -32,9 +32,10 @@ import (
32
32
 
33
33
  // Dependencies carries the replaceable boundaries used by activities.
34
34
  type Dependencies struct {
35
- Repos *repo.Registry
36
- Runner runner.Runner
37
- Harness harness.Harness
35
+ Repos *repo.Registry
36
+ Runner runner.Runner
37
+ Harness harness.Harness
38
+ TaskSystem string
38
39
 
39
40
  // RetentionDays bounds completed/canceled run retention; zero uses the
40
41
  // machine default of 30 days.
@@ -91,6 +92,23 @@ func InitDatabase(path string) error {
91
92
  return nil
92
93
  }
93
94
 
95
+ // HasNonterminalRuns inspects an existing database without migrating or
96
+ // otherwise modifying it. It is used by init --force before config changes.
97
+ func HasNonterminalRuns(path string) (bool, error) {
98
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
99
+ if err != nil {
100
+ return false, fmt.Errorf("open %s: %w", path, err)
101
+ }
102
+ defer db.Close()
103
+ var active bool
104
+ if err := db.QueryRow(`SELECT EXISTS(
105
+ SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
106
+ )`).Scan(&active); err != nil {
107
+ return false, fmt.Errorf("inspect %s: %w", path, err)
108
+ }
109
+ return active, nil
110
+ }
111
+
94
112
  // New opens the SQLite database at path (created with mode 0600 when
95
113
  // missing), migrates the relay_runs projection, and constructs the engine.
96
114
  // A corrupt database file fails here.
@@ -130,10 +148,11 @@ func New(path string, deps Dependencies) (*Engine, error) {
130
148
  return nil, fmt.Errorf("migrate relay_runs: %w", err)
131
149
  }
132
150
  activities := &Activities{
133
- Repos: deps.Repos,
134
- Runner: deps.Runner,
135
- Harness: deps.Harness,
136
- Runs: proj,
151
+ Repos: deps.Repos,
152
+ Runner: deps.Runner,
153
+ Harness: deps.Harness,
154
+ TaskSystem: deps.TaskSystem,
155
+ Runs: proj,
137
156
  }
138
157
  retention := 30 * 24 * time.Hour
139
158
  if deps.RetentionDays > 0 {
@@ -204,6 +223,7 @@ func (e *Engine) registerActivities() error {
204
223
  a.ValidateAgents,
205
224
  a.ApplyTaskConfig,
206
225
  a.EnsureEnvironment,
226
+ a.SetEnvironmentStatus,
207
227
  a.LoadNodeRuntime,
208
228
  a.EnsureNodeRuntime,
209
229
  a.CloseTerminals,
@@ -291,6 +311,19 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
291
311
  if err != nil {
292
312
  return false, err
293
313
  }
314
+ if r.State != run.StateCompleted && r.State != run.StateCanceled && r.State != run.StateCanceling {
315
+ if _, err := e.instance(ctx, start.ID); errors.Is(err, sql.ErrNoRows) {
316
+ start.Runtime = e.runtime
317
+ if _, err := e.client.CreateWorkflowInstance(ctx,
318
+ client.WorkflowInstanceOptions{InstanceID: string(start.ID)},
319
+ e.activities.TicketWorkflow, start); err != nil && !errors.Is(err, backend.ErrInstanceAlreadyExists) {
320
+ return false, fmt.Errorf("create missing workflow instance %s: %w", start.ID, err)
321
+ }
322
+ return true, nil
323
+ } else if err != nil {
324
+ return false, err
325
+ }
326
+ }
294
327
  // Existing run: reconcile only an active run at a work node.
295
328
  if r.State == run.StateCompleted || r.State == run.StateCanceled || r.State == run.StateCanceling {
296
329
  return false, nil
@@ -304,7 +337,7 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
304
337
  }
305
338
  ok := false
306
339
  if runtime.TerminalID != "" {
307
- _, ok, _ = e.activities.Runner.InspectTerminal(ctx, runner.Terminal{
340
+ _, ok, _ = e.activities.Runner.FindTerminal(ctx, runner.Terminal{
308
341
  ID: runtime.TerminalID, Title: r.Ticket.Key + ":" + r.CurrentNode,
309
342
  })
310
343
  }