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,154 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+ "strings"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ )
15
+
16
+ // 9.7: logging is observable behavior. Capture the slog default handler's
17
+ // output for one full node transition (run created → node entered → report
18
+ // persisted → summary written → feedback written → mailbox completed →
19
+ // run completed) and assert the ticket/runID/node attrs appear on each
20
+ // transition line. Uses the seam-(a) fakes and seam-(b) temp SQLite engine
21
+ // that every other engine test uses — no new production hooks.
22
+ func TestNodeTransitionLogsCarryTicketRunNodeAttrs(t *testing.T) {
23
+ h := newCaptureHandler()
24
+ prev := slog.Default()
25
+ slog.SetDefault(slog.New(h))
26
+ t.Cleanup(func() { slog.SetDefault(prev) })
27
+
28
+ log := newEventLog()
29
+ sys := newFakeTaskSystem(log)
30
+ engine := newEngine(t, goworkflows.Dependencies{
31
+ Repos: repoRegistryWith("payments", sys),
32
+ Runner: newFakeRunner(log), Harness: newFakeHarness(log),
33
+ })
34
+ rid, _ := startRun(engine, linearWorkflow(false))
35
+ waitFor(t, 10*time.Second, func() bool {
36
+ r, _ := engine.GetRun(context.Background(), rid)
37
+ return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
38
+ })
39
+ r, _ := engine.GetRun(context.Background(), rid)
40
+ visit := r.CurrentNodeVisitID
41
+
42
+ // coding → end with a feedback-carrying report (NextStep=end means
43
+ // feedback is all None; use a coding→coding failure revisit first so
44
+ // feedback is actually written to a selected next mailbox, then a
45
+ // final coding→end success report).
46
+ loop := successReport("coding")
47
+ loop.Status = workflow.OutcomeFailure
48
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", loop)); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ waitFor(t, 10*time.Second, func() bool {
52
+ r, _ := engine.GetRun(context.Background(), rid)
53
+ return r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != visit
54
+ })
55
+ r, _ = engine.GetRun(context.Background(), rid)
56
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ // Wait for the terminal-state log line itself (not just the projection
60
+ // state): the ProjectionUpdateState activity writes the state and the
61
+ // log line in one call, and waiting on state alone races the log.
62
+ waitFor(t, 10*time.Second, func() bool {
63
+ for _, ln := range h.lines() {
64
+ if ln["msg"] == "run completed" {
65
+ return true
66
+ }
67
+ }
68
+ return false
69
+ })
70
+
71
+ wantTicket := "PAY-101"
72
+ wantRun := string(rid)
73
+
74
+ // The transition lines that must carry the required attrs.
75
+ type expectation struct {
76
+ msg string
77
+ needsNode bool
78
+ needsVisit bool
79
+ }
80
+ expect := []expectation{
81
+ {"run created", false, false},
82
+ {"node entered", true, true},
83
+ {"report persisted", true, true},
84
+ {"summary written", false, true}, // summary carries nodeVisitID via marker
85
+ {"feedback written", true, true}, // feedback carries the selected next node
86
+ {"mailbox completed", true, false},
87
+ {"run completed", false, false},
88
+ }
89
+ lines := h.lines()
90
+ for _, e := range expect {
91
+ var found map[string]string
92
+ for _, ln := range lines {
93
+ if ln["msg"] == e.msg {
94
+ found = ln
95
+ break
96
+ }
97
+ }
98
+ if found == nil {
99
+ t.Fatalf("missing log line %q; captured %d lines:\n%s", e.msg, len(lines), renderLines(lines))
100
+ }
101
+ if found["ticket"] != wantTicket {
102
+ t.Errorf("%s: ticket = %q, want %q", e.msg, found["ticket"], wantTicket)
103
+ }
104
+ if found["runID"] != wantRun {
105
+ t.Errorf("%s: runID = %q, want %q", e.msg, found["runID"], wantRun)
106
+ }
107
+ if e.needsNode && found["node"] == "" {
108
+ t.Errorf("%s: missing node attr", e.msg)
109
+ }
110
+ if e.needsVisit && found["nodeVisitID"] == "" {
111
+ t.Errorf("%s: missing nodeVisitID attr", e.msg)
112
+ }
113
+ }
114
+ }
115
+
116
+ func renderLines(lines []map[string]string) string {
117
+ var b strings.Builder
118
+ for _, ln := range lines {
119
+ fmt.Fprintf(&b, " %v\n", ln)
120
+ }
121
+ return b.String()
122
+ }
123
+
124
+ // captureHandler is an slog.Handler that records each record's message
125
+ // and attrs. Not safe for production use; tests only.
126
+ type captureHandler struct {
127
+ mu sync.Mutex
128
+ recs []map[string]string
129
+ }
130
+
131
+ func newCaptureHandler() *captureHandler { return &captureHandler{} }
132
+
133
+ func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
134
+
135
+ func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
136
+ m := map[string]string{"msg": r.Message, "level": r.Level.String()}
137
+ r.Attrs(func(a slog.Attr) bool {
138
+ m[a.Key] = fmt.Sprint(a.Value.Any())
139
+ return true
140
+ })
141
+ h.mu.Lock()
142
+ h.recs = append(h.recs, m)
143
+ h.mu.Unlock()
144
+ return nil
145
+ }
146
+
147
+ func (h *captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
148
+ func (h *captureHandler) WithGroup(string) slog.Handler { return h }
149
+
150
+ func (h *captureHandler) lines() []map[string]string {
151
+ h.mu.Lock()
152
+ defer h.mu.Unlock()
153
+ return append([]map[string]string(nil), h.recs...)
154
+ }
@@ -0,0 +1,423 @@
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
+ // 3.27-3.28: mailbox lifecycle at the run level per specs/node-mailboxes.
17
+ // Fakes in fakes_test.go record labels, specs, statuses, and comments.
18
+
19
+ func threeNodeWorkflow() workflow.Workflow {
20
+ return workflow.Workflow{
21
+ Name: "threeNode", Repos: []string{"payments"},
22
+ Nodes: map[string]workflow.Node{
23
+ "start": {OnSuccess: []workflow.Route{{Target: "exploration"}}},
24
+ "exploration": {
25
+ Type: workflow.NodeAgent, Agent: "build", Description: "explore the code",
26
+ OnSuccess: []workflow.Route{{Target: "coding", When: "explored"}},
27
+ OnFailure: []workflow.Route{{Target: "exploration"}},
28
+ },
29
+ "coding": {
30
+ Type: workflow.NodeAgent, Agent: "build", Description: "write the code",
31
+ OnSuccess: []workflow.Route{{Target: "review", When: "coded"}},
32
+ OnFailure: []workflow.Route{{Target: "coding"}},
33
+ },
34
+ "review": {
35
+ Type: workflow.NodeHITL, Agent: "reviewer", Description: "review the diff",
36
+ OnSuccess: []workflow.Route{{Target: "end"}},
37
+ OnFailure: []workflow.Route{{Target: "coding", When: "changes needed"}},
38
+ },
39
+ "end": {},
40
+ },
41
+ }
42
+ }
43
+
44
+ func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
45
+ log := newEventLog()
46
+ sys := newFakeTaskSystem(log)
47
+ engine := newEngine(t, goworkflows.Dependencies{
48
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
49
+ })
50
+ rid, _ := startRun(engine, threeNodeWorkflow())
51
+ waitFor(t, 10*time.Second, func() bool {
52
+ r, _ := engine.GetRun(context.Background(), rid)
53
+ return r.CurrentNode == "exploration"
54
+ })
55
+
56
+ if len(sys.specs) == 0 {
57
+ t.Fatal("EnsureMailboxes never called")
58
+ }
59
+ nodes := map[string]task.MailboxSpec{}
60
+ for _, sp := range sys.specs {
61
+ nodes[sp.Node] = sp
62
+ if sp.Node == "start" || sp.Node == "end" {
63
+ t.Fatalf("mailbox spec for reserved node %q; start/end get none", sp.Node)
64
+ }
65
+ if sp.Title != "PAY-101:"+sp.Node {
66
+ t.Fatalf("mailbox title = %q, want <ticket>:<node>", sp.Title)
67
+ }
68
+ }
69
+ // Each node's description must carry its type, agent, every legal success
70
+ // and failure route target, and the explanation (When) for each route that
71
+ // has one.
72
+ wantRoutes := map[string]struct {
73
+ agent string
74
+ ntype string
75
+ targets []string // success + failure
76
+ whens []string // configured route explanations
77
+ }{
78
+ "exploration": {agent: "build", ntype: "agent", targets: []string{"coding", "exploration"}, whens: []string{"explored"}},
79
+ "coding": {agent: "build", ntype: "agent", targets: []string{"review", "coding"}, whens: []string{"coded"}},
80
+ "review": {agent: "reviewer", ntype: "hitl", targets: []string{"end", "coding"}, whens: []string{"changes needed"}},
81
+ }
82
+ for node, want := range wantRoutes {
83
+ sp, ok := nodes[node]
84
+ if !ok {
85
+ t.Fatalf("no mailbox spec for work node %q", node)
86
+ }
87
+ d := sp.Description
88
+ if !strings.Contains(d, node) {
89
+ t.Fatalf("%s description lacks node name: %q", node, d)
90
+ }
91
+ if !strings.Contains(d, "PAY-101") {
92
+ t.Fatalf("%s description lacks parent identity: %q", node, d)
93
+ }
94
+ if !strings.Contains(d, want.agent) {
95
+ t.Fatalf("%s description lacks agent %q: %q", node, want.agent, d)
96
+ }
97
+ if !strings.Contains(d, want.ntype) {
98
+ t.Fatalf("%s description lacks node type %q: %q", node, want.ntype, d)
99
+ }
100
+ for _, target := range want.targets {
101
+ if !strings.Contains(d, target) {
102
+ t.Fatalf("%s description lacks legal route target %q: %q", node, target, d)
103
+ }
104
+ }
105
+ for _, when := range want.whens {
106
+ if !strings.Contains(d, when) {
107
+ t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
108
+ }
109
+ }
110
+ for _, required := range []string{"Required report format:", "STATUS:", "COMMITS:", "FEEDBACK:"} {
111
+ if !strings.Contains(d, required) {
112
+ t.Fatalf("%s description lacks %q: %q", node, required, d)
113
+ }
114
+ }
115
+ }
116
+ // The exploration description carries its node work text.
117
+ if !strings.Contains(nodes["exploration"].Description, "explore the code") {
118
+ t.Fatalf("exploration description lacks node work: %q", nodes["exploration"].Description)
119
+ }
120
+ }
121
+
122
+ func TestMailboxCarriesWorkflowLabel(t *testing.T) {
123
+ log := newEventLog()
124
+ sys := newFakeTaskSystem(log)
125
+ engine := newEngine(t, goworkflows.Dependencies{
126
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
127
+ })
128
+ rid, _ := startRun(engine, threeNodeWorkflow())
129
+ waitFor(t, 10*time.Second, func() bool {
130
+ r, _ := engine.GetRun(context.Background(), rid)
131
+ return r.CurrentNode == "exploration"
132
+ })
133
+ for node, mb := range sys.mailboxesSnapshot() {
134
+ labels := sys.labelsFor(mb.Key)
135
+ found := false
136
+ for _, l := range labels {
137
+ if l == "wf:threeNode" {
138
+ found = true
139
+ }
140
+ }
141
+ if !found {
142
+ t.Fatalf("mailbox %s (node %s) missing wf:threeNode label: %v", mb.Key, node, labels)
143
+ }
144
+ }
145
+ }
146
+
147
+ func TestRevisitReusesSameMailbox(t *testing.T) {
148
+ log := newEventLog()
149
+ sys := newFakeTaskSystem(log)
150
+ engine := newEngine(t, goworkflows.Dependencies{
151
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
152
+ })
153
+ rid, _ := startRun(engine, threeNodeWorkflow())
154
+ waitFor(t, 10*time.Second, func() bool {
155
+ r, _ := engine.GetRun(context.Background(), rid)
156
+ return r.CurrentNode == "exploration"
157
+ })
158
+ r, _ := engine.GetRun(context.Background(), rid)
159
+ firstVisit := r.CurrentNodeVisitID
160
+ firstMailbox, _ := sys.mailboxFor("PAY-101", "exploration")
161
+
162
+ // exploration failure -> exploration again (revisit).
163
+ fail := successReport("exploration")
164
+ fail.Status = workflow.OutcomeFailure
165
+ fail.Feedback = workflow.Feedback{
166
+ ReasonForNextStep: "more", RequiredActions: "explore more",
167
+ RelevantContext: "None", ExpectedResult: "done",
168
+ }
169
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", fail)); err != nil {
170
+ t.Fatal(err)
171
+ }
172
+ waitFor(t, 10*time.Second, func() bool {
173
+ r, _ := engine.GetRun(context.Background(), rid)
174
+ return r.CurrentNode == "exploration" && r.CurrentNodeVisitID != "" && r.CurrentNodeVisitID != firstVisit
175
+ })
176
+ // Same mailbox reused (found, not recreated); new visit ID differs.
177
+ r2, _ := engine.GetRun(context.Background(), rid)
178
+ if r2.CurrentNodeVisitID == firstVisit {
179
+ t.Fatal("revisit kept the same nodeVisitID; each entry must get a fresh one")
180
+ }
181
+ if cur, _ := sys.mailboxFor("PAY-101", "exploration"); cur != firstMailbox {
182
+ t.Fatal("revisit created a new exploration mailbox instead of reusing it")
183
+ }
184
+ if log.count("createMailbox:PAY-101:exploration") != 1 {
185
+ t.Fatalf("exploration mailbox created %d times, want 1 (reused on revisit)", log.count("createMailbox:PAY-101:exploration"))
186
+ }
187
+ }
188
+
189
+ func TestSummaryMarkerAndContentOnCurrentMailbox(t *testing.T) {
190
+ log := newEventLog()
191
+ sys := newFakeTaskSystem(log)
192
+ engine := newEngine(t, goworkflows.Dependencies{
193
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
194
+ })
195
+ rid, _ := startRun(engine, linearWorkflow(false))
196
+ waitFor(t, 10*time.Second, func() bool {
197
+ r, _ := engine.GetRun(context.Background(), rid)
198
+ return r.CurrentNode == "coding"
199
+ })
200
+ r, _ := engine.GetRun(context.Background(), rid)
201
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
202
+ t.Fatal(err)
203
+ }
204
+ waitFor(t, 10*time.Second, func() bool {
205
+ r, _ := engine.GetRun(context.Background(), rid)
206
+ return r.State == run.StateCompleted
207
+ })
208
+
209
+ summaries := sys.commentBodies("PAY-101-coding")
210
+ if len(summaries) != 1 {
211
+ t.Fatalf("coding summaries = %d, want 1", len(summaries))
212
+ }
213
+ s := summaries[0]
214
+ // Human-readable summary content from the report.
215
+ for _, want := range []string{"done", "COMMITS:", "abc123"} {
216
+ if !strings.Contains(s.Body, want) {
217
+ t.Fatalf("summary body lacks %q: %q", want, s.Body)
218
+ }
219
+ }
220
+ // Stable marker derived from nodeVisitID and comment type.
221
+ if !strings.Contains(s.Marker, string(r.CurrentNodeVisitID)) {
222
+ t.Fatalf("summary marker %q not derived from nodeVisitID %q", s.Marker, r.CurrentNodeVisitID)
223
+ }
224
+ }
225
+
226
+ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
227
+ log := newEventLog()
228
+ sys := newFakeTaskSystem(log)
229
+ engine := newEngine(t, goworkflows.Dependencies{
230
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
231
+ })
232
+ rid, _ := startRun(engine, threeNodeWorkflow())
233
+ waitFor(t, 10*time.Second, func() bool {
234
+ r, _ := engine.GetRun(context.Background(), rid)
235
+ return r.CurrentNode == "exploration"
236
+ })
237
+ report := successReport("coding")
238
+ report.Feedback = workflow.Feedback{
239
+ ReasonForNextStep: "explored", RequiredActions: "code it",
240
+ RelevantContext: "ctx", ExpectedResult: "working code",
241
+ }
242
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ waitFor(t, 10*time.Second, func() bool {
246
+ r, _ := engine.GetRun(context.Background(), rid)
247
+ return r.CurrentNode == "coding"
248
+ })
249
+
250
+ if len(sys.commentBodies("PAY-101-exploration")) == 0 {
251
+ t.Fatal("summary not written to current exploration mailbox")
252
+ }
253
+ // Feedback to the selected next mailbox carries every feedback field.
254
+ fbs := sys.commentBodies("PAY-101-coding")
255
+ if len(fbs) == 0 {
256
+ t.Fatal("feedback not written to selected coding mailbox")
257
+ }
258
+ for _, want := range []string{"Feedback from exploration", "COMMITS:", "abc123", "explored", "code it", "ctx", "working code"} {
259
+ if !strings.Contains(fbs[0].Body, want) {
260
+ t.Fatalf("feedback body missing %q: %q", want, fbs[0].Body)
261
+ }
262
+ }
263
+ // Unrelated mailbox (review) gets nothing.
264
+ if len(sys.commentBodies("PAY-101-review")) != 0 {
265
+ t.Fatal("feedback written to unrelated review mailbox")
266
+ }
267
+ }
268
+
269
+ // 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
270
+
271
+ func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
272
+ log := newEventLog()
273
+ sys := newFakeTaskSystem(log)
274
+ engine := newEngine(t, goworkflows.Dependencies{
275
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
276
+ })
277
+ rid, _ := startRun(engine, linearWorkflow(false))
278
+ waitFor(t, 10*time.Second, func() bool {
279
+ r, _ := engine.GetRun(context.Background(), rid)
280
+ return r.CurrentNode == "coding"
281
+ })
282
+
283
+ // A human moves the coding mailbox to Done without a structured report.
284
+ sys.setMailboxStatus("PAY-101-coding", "Done")
285
+
286
+ // Reconcile/poll (EnsureRun) must not infer success or route the graph.
287
+ wf := linearWorkflow(false)
288
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
289
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
290
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
291
+ }); err != nil {
292
+ t.Fatal(err)
293
+ }
294
+ time.Sleep(500 * time.Millisecond)
295
+ r, _ := engine.GetRun(context.Background(), rid)
296
+ if r.CurrentNode != "coding" {
297
+ t.Fatalf("graph advanced to %q on a manual mailbox status change", r.CurrentNode)
298
+ }
299
+ if r.State == run.StateCompleted {
300
+ t.Fatal("run completed without a structured report")
301
+ }
302
+ // No new node terminal scheduled.
303
+ if log.count("ensureTerminal:PAY-101:review") != 0 {
304
+ t.Fatal("manual mailbox status change scheduled the next node")
305
+ }
306
+ }
307
+
308
+ func TestHITLUsesSameMailboxLifecycle(t *testing.T) {
309
+ log := newEventLog()
310
+ sys := newFakeTaskSystem(log)
311
+ engine := newEngine(t, goworkflows.Dependencies{
312
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
313
+ })
314
+ wf := workflow.Workflow{
315
+ Name: "hitlFlow", Repos: []string{"payments"},
316
+ Nodes: map[string]workflow.Node{
317
+ "start": {OnSuccess: []workflow.Route{{Target: "review"}}},
318
+ "review": {
319
+ Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
320
+ OnSuccess: []workflow.Route{{Target: "end"}},
321
+ OnFailure: []workflow.Route{{Target: "review"}},
322
+ },
323
+ "end": {},
324
+ },
325
+ }
326
+ rid := identity.NewRunID("payments", "hitlFlow", "PAY-101")
327
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
328
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
329
+ Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
330
+ }); err != nil {
331
+ t.Fatal(err)
332
+ }
333
+ waitFor(t, 10*time.Second, func() bool {
334
+ r, _ := engine.GetRun(context.Background(), rid)
335
+ return r.CurrentNode == "review"
336
+ })
337
+
338
+ // HITL review mailbox exists with the wf label.
339
+ mb, ok := sys.mailboxFor("PAY-101", "review")
340
+ if !ok {
341
+ t.Fatal("no HITL review mailbox ensured")
342
+ }
343
+ found := false
344
+ for _, l := range sys.labelsFor(mb.Key) {
345
+ if l == "wf:hitlFlow" {
346
+ found = true
347
+ }
348
+ }
349
+ if !found {
350
+ t.Fatalf("HITL mailbox missing wf label: %v", sys.labelsFor(mb.Key))
351
+ }
352
+
353
+ // A valid HITL report advances the run through the same lifecycle.
354
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "review", successReport("end"))); err != nil {
355
+ t.Fatal(err)
356
+ }
357
+ waitFor(t, 10*time.Second, func() bool {
358
+ r, _ := engine.GetRun(context.Background(), rid)
359
+ return r.State == run.StateCompleted
360
+ })
361
+ if len(sys.commentBodies(mb.Key)) == 0 {
362
+ t.Fatal("HITL summary not recorded in its mailbox")
363
+ }
364
+ }
365
+
366
+ func TestRecoveryReusesMailboxesCreatesOnlyMissing(t *testing.T) {
367
+ // Recovery mailbox semantics through the documented task.System
368
+ // primitives only: EnsureMailboxes finds existing and creates only
369
+ // missing, then ResetForRecovery resets them to To Do while preserving
370
+ // comments/labels. The full serve --recover composition is section 5.6.
371
+ log := newEventLog()
372
+ sys := newFakeTaskSystem(log)
373
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
374
+
375
+ // Pre-existing exploration mailbox (with a comment and label to keep).
376
+ sys.seedMailbox("PAY-101", task.Mailbox{ID: "mb-exploration", Key: "PAY-101-exploration", Node: "exploration"}, []string{"wf:threeNode"})
377
+ sys.Comment(context.Background(), task.Target{Parent: parent, Mailbox: mailboxPtr(sys, "PAY-101", "exploration")}, "old summary", "old")
378
+
379
+ specs := []task.MailboxSpec{
380
+ {Node: "exploration", Title: "PAY-101:exploration", Description: "explore"},
381
+ {Node: "coding", Title: "PAY-101:coding", Description: "code"},
382
+ {Node: "review", Title: "PAY-101:review", Description: "review"},
383
+ }
384
+ mbs, err := sys.EnsureMailboxes(context.Background(), parent, "threeNode", specs)
385
+ if err != nil {
386
+ t.Fatal(err)
387
+ }
388
+ if len(mbs) != 3 {
389
+ t.Fatalf("EnsureMailboxes = %d, want complete map of 3", len(mbs))
390
+ }
391
+ if log.count("foundMailbox:PAY-101:exploration") != 1 {
392
+ t.Fatal("existing exploration mailbox not found/reused")
393
+ }
394
+ if log.count("createMailbox:PAY-101:exploration") != 0 {
395
+ t.Fatal("existing exploration mailbox recreated")
396
+ }
397
+ if log.count("createMailbox:PAY-101:coding") != 1 || log.count("createMailbox:PAY-101:review") != 1 {
398
+ t.Fatal("missing mailboxes not created exactly once")
399
+ }
400
+
401
+ // Reset to To Do, preserving the existing comment/label.
402
+ var mbList []task.Mailbox
403
+ for _, mb := range mbs {
404
+ mbList = append(mbList, mb)
405
+ }
406
+ if err := sys.ResetForRecovery(context.Background(), parent, mbList, nil); err != nil {
407
+ t.Fatal(err)
408
+ }
409
+ if sys.mailboxStatusOf("PAY-101-exploration") != "To Do" {
410
+ t.Fatal("mailbox not reset to To Do")
411
+ }
412
+ if len(sys.commentBodies("PAY-101-exploration")) == 0 {
413
+ t.Fatal("reset dropped existing mailbox comment")
414
+ }
415
+ if len(sys.labelsFor("PAY-101-exploration")) == 0 {
416
+ t.Fatal("reset dropped mailbox wf label")
417
+ }
418
+ }
419
+
420
+ func mailboxPtr(sys *fakeTaskSystem, parentKey, node string) *task.Mailbox {
421
+ mb, _ := sys.mailboxFor(parentKey, node)
422
+ return &mb
423
+ }