relay-flow 0.2.1-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.
- package/README.md +155 -22
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +74 -0
- package/cmd/relay-flow/main.go +25 -0
- package/cmd/relay-flow/scenario_test.go +48 -12
- package/cmd/relay-flow/serve.go +4 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +6 -6
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +105 -56
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine_test.go +33 -15
- package/internal/execution/goworkflows/fakes_test.go +50 -4
- package/internal/execution/goworkflows/interpreter.go +36 -29
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_test.go +30 -5
- package/internal/execution/goworkflows/recovery_test.go +2 -2
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/harness/contract_test.go +10 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/recover/recover.go +11 -6
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +51 -2
- package/internal/run/run.go +6 -4
- package/internal/task/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/empty.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +10 -0
- package/internal/task/factory.go +37 -4
- package/internal/task/jira/auth.go +27 -1
- package/internal/task/jira/auth_test.go +54 -1
- package/internal/task/jira/filters_test.go +60 -0
- package/internal/task/jira/jira.go +171 -29
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/rest/adf.go +119 -82
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client_test.go +1 -1
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +4 -2
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,8 @@ import (
|
|
|
9
9
|
"time"
|
|
10
10
|
|
|
11
11
|
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/harness/opencode"
|
|
12
14
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
13
15
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
14
16
|
"github.com/rajpopat27/relay-flow/internal/run"
|
|
@@ -44,33 +46,34 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
|
|
|
44
46
|
wf := linearWorkflow(false)
|
|
45
47
|
node := wf.Nodes["coding"]
|
|
46
48
|
node.Type = workflow.NodeHITL
|
|
47
|
-
|
|
49
|
+
spec := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node)
|
|
50
|
+
description := spec.Description
|
|
48
51
|
for _, want := range []string{
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
+
"Required report format:",
|
|
53
|
+
"Node names identify workflow stages",
|
|
54
|
+
"SUMMARY is written to this current mailbox",
|
|
55
|
+
"requested changes in FEEDBACK",
|
|
52
56
|
} {
|
|
53
57
|
if !strings.Contains(description, want) {
|
|
54
58
|
t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
|
-
for _, unwanted := range []string{"Jira", "OpenCode", "Question tool"} {
|
|
61
|
+
for _, unwanted := range []string{"Jira", "OpenCode", "Question tool", "Approve and Reject", "Discuss the task with the human"} {
|
|
58
62
|
if strings.Contains(description, unwanted) {
|
|
59
63
|
t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
node.Type = workflow.NodeAgent
|
|
64
|
-
agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
|
|
65
|
-
if strings.Contains(agentDescription, "Question tool") {
|
|
66
|
-
t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
67
|
for _, taskSystem := range []string{"jira", "linear"} {
|
|
70
|
-
prompt :=
|
|
71
|
-
|
|
68
|
+
prompt, err := opencode.New().RenderPrompt(harness.PromptInitial, harness.PromptData{
|
|
69
|
+
TaskSystem: taskSystem, Ticket: "PAY-101", Mailbox: "PAY-234", NodeType: workflow.NodeHITL,
|
|
70
|
+
}, "")
|
|
71
|
+
if err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nBefore submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer."
|
|
72
75
|
if prompt != want {
|
|
73
|
-
t.Fatalf("
|
|
76
|
+
t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
|
|
74
77
|
}
|
|
75
78
|
if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
|
|
76
79
|
t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
|
|
@@ -182,7 +185,11 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
|
|
|
182
185
|
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
183
186
|
})
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
wf := linearWorkflow(false)
|
|
189
|
+
node := wf.Nodes["coding"]
|
|
190
|
+
node.NudgePrompt = "Continue {{ticket}} at {{node}}. Valid next steps: {{nextSteps}}."
|
|
191
|
+
wf.Nodes["coding"] = node
|
|
192
|
+
rid, err := startRun(engine, wf)
|
|
186
193
|
if err != nil {
|
|
187
194
|
t.Fatalf("EnsureRun failed: %v", err)
|
|
188
195
|
}
|
|
@@ -206,6 +213,17 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
|
|
|
206
213
|
if runtime.TerminalID == "" || runtime.NodeVisitID != r.CurrentNodeVisitID {
|
|
207
214
|
t.Fatalf("terminal was not persisted for current visit: %+v", runtime)
|
|
208
215
|
}
|
|
216
|
+
promptCalls := fh.promptCalls()
|
|
217
|
+
if len(promptCalls) != 1 {
|
|
218
|
+
t.Fatalf("RenderPrompt calls = %+v, want one initial prompt", promptCalls)
|
|
219
|
+
}
|
|
220
|
+
call := promptCalls[0]
|
|
221
|
+
if call.NudgeTemplate != node.NudgePrompt {
|
|
222
|
+
t.Fatalf("nudge passed to harness = %q, want raw template %q", call.NudgeTemplate, node.NudgePrompt)
|
|
223
|
+
}
|
|
224
|
+
if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.NextSteps == "" {
|
|
225
|
+
t.Fatalf("nudge prompt data = %+v, want current workflow values", call.Data)
|
|
226
|
+
}
|
|
209
227
|
|
|
210
228
|
// Pre-edge gate: before following the start edge the run ensures the
|
|
211
229
|
// runner environment AND validates every referenced agent, and applies
|
|
@@ -74,6 +74,7 @@ type fakeTaskSystem struct {
|
|
|
74
74
|
specs []task.MailboxSpec
|
|
75
75
|
comments []recordedComment
|
|
76
76
|
resets []string
|
|
77
|
+
renderText func(task.TextKind, task.TextData) (string, error)
|
|
77
78
|
|
|
78
79
|
// Failure/crash/slow injection — the fake IS the documented injection
|
|
79
80
|
// seam (allowed seam a). These make the fake adapter fail/stall so the
|
|
@@ -122,6 +123,22 @@ func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[s
|
|
|
122
123
|
return nil
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
func (s *fakeTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
|
|
127
|
+
if s.renderText != nil {
|
|
128
|
+
return s.renderText(kind, data)
|
|
129
|
+
}
|
|
130
|
+
switch kind {
|
|
131
|
+
case task.TextMailboxDescription:
|
|
132
|
+
return "Parent ticket: " + data.Ticket + "\nNode: " + data.Node + "\nType: " + data.NodeType + "\nAgent: " + data.Agent + "\nWork: " + data.NodeDescription + "\nMailbox: " + data.Mailbox, nil
|
|
133
|
+
case task.TextSummaryComment:
|
|
134
|
+
return "SUMMARY\n" + data.SummaryReport, nil
|
|
135
|
+
case task.TextFeedbackComment:
|
|
136
|
+
return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
|
|
137
|
+
default:
|
|
138
|
+
return "", fmt.Errorf("unknown task text kind %q", kind)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
|
|
126
143
|
s.log.add("ensureMailboxes:" + parent.Key)
|
|
127
144
|
s.mu.Lock()
|
|
@@ -455,16 +472,25 @@ func (f *fakeRunner) killTerminals() {
|
|
|
455
472
|
type fakeHarness struct {
|
|
456
473
|
log *eventLog
|
|
457
474
|
|
|
458
|
-
mu
|
|
459
|
-
validated
|
|
460
|
-
sessions
|
|
461
|
-
|
|
475
|
+
mu sync.Mutex
|
|
476
|
+
validated []string
|
|
477
|
+
sessions map[string]harness.Session
|
|
478
|
+
renderedPrompts []renderedPromptCall
|
|
479
|
+
reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
type renderedPromptCall struct {
|
|
483
|
+
Kind harness.PromptKind
|
|
484
|
+
Data harness.PromptData
|
|
485
|
+
NudgeTemplate string
|
|
462
486
|
}
|
|
463
487
|
|
|
464
488
|
func newFakeHarness(log *eventLog) *fakeHarness {
|
|
465
489
|
return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
|
|
466
490
|
}
|
|
467
491
|
|
|
492
|
+
func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
|
|
493
|
+
|
|
468
494
|
func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
469
495
|
f.mu.Lock()
|
|
470
496
|
f.validated = append(f.validated, agent)
|
|
@@ -481,6 +507,26 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
|
|
|
481
507
|
return s, ok, nil
|
|
482
508
|
}
|
|
483
509
|
|
|
510
|
+
func (f *fakeHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
|
|
511
|
+
f.mu.Lock()
|
|
512
|
+
f.renderedPrompts = append(f.renderedPrompts, renderedPromptCall{Kind: kind, Data: data, NudgeTemplate: nudge})
|
|
513
|
+
f.mu.Unlock()
|
|
514
|
+
prompt := string(kind) + ":" + data.TaskSystem + ":" + data.Mailbox
|
|
515
|
+
if data.NodeType == workflow.NodeHITL {
|
|
516
|
+
prompt += ":hitl"
|
|
517
|
+
}
|
|
518
|
+
if nudge != "" {
|
|
519
|
+
prompt += ":" + nudge
|
|
520
|
+
}
|
|
521
|
+
return prompt, nil
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
func (f *fakeHarness) promptCalls() []renderedPromptCall {
|
|
525
|
+
f.mu.Lock()
|
|
526
|
+
defer f.mu.Unlock()
|
|
527
|
+
return append([]renderedPromptCall(nil), f.renderedPrompts...)
|
|
528
|
+
}
|
|
529
|
+
|
|
484
530
|
func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
485
531
|
f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
|
|
486
532
|
return runner.Command{Executable: "opencode"}, nil
|
|
@@ -188,22 +188,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
188
188
|
|
|
189
189
|
title := start.Ticket.Key + ":" + current
|
|
190
190
|
|
|
191
|
-
// Build
|
|
192
|
-
//
|
|
193
|
-
// complete report contract, and valid next steps. Custom instructions
|
|
194
|
-
// are also retained separately for live-terminal revisits.
|
|
191
|
+
// Build task-system-neutral prompt data from the workflow snapshot. The
|
|
192
|
+
// selected harness owns rendering initial, feedback, and HITL text.
|
|
195
193
|
nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
|
|
196
|
-
nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
|
|
197
|
-
TaskSystem: a.TaskSystem,
|
|
198
|
-
Ticket: start.Ticket.Key,
|
|
199
|
-
Workflow: wf.Name,
|
|
200
|
-
Repo: start.Repo,
|
|
201
|
-
Node: current,
|
|
202
|
-
NextSteps: nextStepsText(nextSteps),
|
|
203
|
-
})
|
|
204
|
-
if err != nil {
|
|
205
|
-
return err
|
|
206
|
-
}
|
|
207
194
|
spec := harness.LaunchSpec{
|
|
208
195
|
RunID: start.ID,
|
|
209
196
|
NodeVisitID: visitID,
|
|
@@ -215,9 +202,20 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
215
202
|
NodeType: node.Type,
|
|
216
203
|
Agent: node.Agent,
|
|
217
204
|
Title: title,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
205
|
+
NudgePrompt: node.NudgePrompt,
|
|
206
|
+
PromptData: harness.PromptData{
|
|
207
|
+
TaskSystem: a.TaskSystem,
|
|
208
|
+
Ticket: start.Ticket.Key,
|
|
209
|
+
Workflow: wf.Name,
|
|
210
|
+
Repo: start.Repo,
|
|
211
|
+
Node: current,
|
|
212
|
+
NodeType: node.Type,
|
|
213
|
+
Agent: node.Agent,
|
|
214
|
+
NodeDescription: node.Description,
|
|
215
|
+
NextSteps: nextStepsText(nextSteps),
|
|
216
|
+
Mailbox: mb.Key,
|
|
217
|
+
},
|
|
218
|
+
NextSteps: nextSteps,
|
|
221
219
|
}
|
|
222
220
|
if runtime.SessionID != "" {
|
|
223
221
|
spec.ResumeID = runtime.SessionID
|
|
@@ -305,10 +303,14 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
305
303
|
// complete current -> process next node.
|
|
306
304
|
if _, err := retryLoop(ctx, start.ID, a, work, current,
|
|
307
305
|
func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
|
|
306
|
+
summaryReport := renderSummaryReport(report)
|
|
308
307
|
return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
|
|
309
|
-
RunID:
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &mb},
|
|
309
|
+
TextKind: task.TextSummaryComment,
|
|
310
|
+
TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
|
|
311
|
+
Workflow: wf.Name, Repo: start.Repo, Node: current, NodeType: string(node.Type),
|
|
312
|
+
Agent: node.Agent, NodeDescription: node.Description, Mailbox: mb.Key,
|
|
313
|
+
SourceNode: current, TargetNode: current, SummaryReport: summaryReport},
|
|
312
314
|
Marker: string(visitID) + ":summary",
|
|
313
315
|
})
|
|
314
316
|
}); err != nil {
|
|
@@ -320,10 +322,15 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
320
322
|
nextMb := mailboxes[next]
|
|
321
323
|
if _, err := retryLoop(ctx, start.ID, a, work, current,
|
|
322
324
|
func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
|
|
325
|
+
feedbackReport := renderFeedbackReport(report)
|
|
323
326
|
return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
|
|
324
|
-
RunID:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
+
RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
|
|
328
|
+
TextKind: task.TextFeedbackComment,
|
|
329
|
+
TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
|
|
330
|
+
Workflow: wf.Name, Repo: start.Repo, Node: next, NodeType: string(wf.Nodes[next].Type),
|
|
331
|
+
Agent: wf.Nodes[next].Agent, NodeDescription: wf.Nodes[next].Description, Mailbox: nextMb.Key,
|
|
332
|
+
SourceNode: current, TargetNode: next, SummaryReport: renderSummaryReport(report),
|
|
333
|
+
FeedbackReport: feedbackReport},
|
|
327
334
|
Marker: string(visitID) + ":feedback",
|
|
328
335
|
})
|
|
329
336
|
}); err != nil {
|
|
@@ -602,12 +609,12 @@ func nextStepsText(routes []workflow.Route) string {
|
|
|
602
609
|
return b.String()
|
|
603
610
|
}
|
|
604
611
|
|
|
605
|
-
func
|
|
606
|
-
return fmt.Sprintf("
|
|
612
|
+
func renderSummaryReport(r workflow.Report) string {
|
|
613
|
+
return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
|
|
607
614
|
r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
|
|
608
615
|
}
|
|
609
616
|
|
|
610
|
-
func
|
|
611
|
-
return fmt.Sprintf("
|
|
612
|
-
|
|
617
|
+
func renderFeedbackReport(r workflow.Report) string {
|
|
618
|
+
return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
|
|
619
|
+
r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
|
|
613
620
|
}
|
|
@@ -266,6 +266,91 @@ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
func TestTaskSystemTemplatesRenderMailboxAndSplitOneReport(t *testing.T) {
|
|
270
|
+
log := newEventLog()
|
|
271
|
+
sys := newFakeTaskSystem(log)
|
|
272
|
+
seen := map[task.TextKind][]task.TextData{}
|
|
273
|
+
sys.renderText = func(kind task.TextKind, data task.TextData) (string, error) {
|
|
274
|
+
seen[kind] = append(seen[kind], data)
|
|
275
|
+
switch kind {
|
|
276
|
+
case task.TextMailboxDescription:
|
|
277
|
+
return "custom mailbox " + data.Node + " work=" + data.NodeDescription, nil
|
|
278
|
+
case task.TextSummaryComment:
|
|
279
|
+
return "custom summary node=" + data.Node + " mailbox=" + data.Mailbox + "\n" + data.SummaryReport, nil
|
|
280
|
+
case task.TextFeedbackComment:
|
|
281
|
+
return "custom feedback source=" + data.SourceNode + " target=" + data.TargetNode + " mailbox=" + data.Mailbox + "\n" + data.FeedbackReport, nil
|
|
282
|
+
default:
|
|
283
|
+
return "", nil
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
287
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log), TaskSystem: "custom-task",
|
|
288
|
+
})
|
|
289
|
+
rid, _ := startRun(engine, threeNodeWorkflow())
|
|
290
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
291
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
292
|
+
return r.CurrentNode == "exploration"
|
|
293
|
+
})
|
|
294
|
+
if got := sys.specs[1].Description; !strings.Contains(got, "custom mailbox exploration work=explore the code") || !strings.Contains(got, "Required report format:") {
|
|
295
|
+
t.Fatalf("rendered mailbox description = %q", got)
|
|
296
|
+
}
|
|
297
|
+
var explorationData task.TextData
|
|
298
|
+
for _, data := range seen[task.TextMailboxDescription] {
|
|
299
|
+
if data.Node == "exploration" {
|
|
300
|
+
explorationData = data
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for name, got := range map[string]string{
|
|
304
|
+
"runID": explorationData.RunID,
|
|
305
|
+
"ticket": explorationData.Ticket, "workflow": explorationData.Workflow,
|
|
306
|
+
"repo": explorationData.Repo, "node": explorationData.Node,
|
|
307
|
+
"nodeType": explorationData.NodeType, "agent": explorationData.Agent,
|
|
308
|
+
"nodeDescription": explorationData.NodeDescription, "mailbox": explorationData.Mailbox,
|
|
309
|
+
} {
|
|
310
|
+
if got == "" {
|
|
311
|
+
t.Fatalf("mailbox template value %s was empty: %+v", name, explorationData)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for name, got := range map[string]string{"nextSteps": explorationData.NextSteps, "successRoutes": explorationData.SuccessRoutes, "failureRoutes": explorationData.FailureRoutes} {
|
|
315
|
+
if !strings.Contains(got, "coding") && name != "failureRoutes" {
|
|
316
|
+
t.Fatalf("mailbox template %s = %q", name, got)
|
|
317
|
+
}
|
|
318
|
+
if name == "failureRoutes" && !strings.Contains(got, "exploration") {
|
|
319
|
+
t.Fatalf("mailbox template failureRoutes = %q", got)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
report := successReport("coding")
|
|
323
|
+
report.Feedback = workflow.Feedback{ReasonForNextStep: "reviewed", RequiredActions: "implement", RelevantContext: "ctx", ExpectedResult: "done"}
|
|
324
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
|
|
325
|
+
t.Fatal(err)
|
|
326
|
+
}
|
|
327
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
328
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
329
|
+
return r.CurrentNode == "coding"
|
|
330
|
+
})
|
|
331
|
+
summary := sys.commentBodies("PAY-101-exploration")
|
|
332
|
+
feedback := sys.commentBodies("PAY-101-coding")
|
|
333
|
+
if len(summary) != 1 || !strings.Contains(summary[0].Body, "custom summary node=exploration mailbox=PAY-101-exploration") || !strings.Contains(summary[0].Body, "COMPLETED:") {
|
|
334
|
+
t.Fatalf("summary comments = %+v", summary)
|
|
335
|
+
}
|
|
336
|
+
if len(feedback) != 1 || !strings.Contains(feedback[0].Body, "source=exploration target=coding mailbox=PAY-101-coding") || !strings.Contains(feedback[0].Body, "REQUIRED ACTIONS:") {
|
|
337
|
+
t.Fatalf("feedback comments = %+v", feedback)
|
|
338
|
+
}
|
|
339
|
+
if len(seen[task.TextSummaryComment]) != 1 || seen[task.TextSummaryComment][0].SummaryReport == "" {
|
|
340
|
+
t.Fatalf("summary template data = %+v", seen[task.TextSummaryComment])
|
|
341
|
+
}
|
|
342
|
+
if len(seen[task.TextFeedbackComment]) != 1 {
|
|
343
|
+
t.Fatalf("feedback template data = %+v", seen[task.TextFeedbackComment])
|
|
344
|
+
}
|
|
345
|
+
feedbackData := seen[task.TextFeedbackComment][0]
|
|
346
|
+
if feedbackData.SourceNode != "exploration" || feedbackData.TargetNode != "coding" || feedbackData.Mailbox != "PAY-101-coding" || feedbackData.FeedbackReport == "" {
|
|
347
|
+
t.Fatalf("feedback template data = %+v", feedbackData)
|
|
348
|
+
}
|
|
349
|
+
if len(sys.commentBodies("PAY-101-review")) != 0 {
|
|
350
|
+
t.Fatal("one report sent feedback to an unselected mailbox")
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
269
354
|
// 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
|
|
270
355
|
|
|
271
356
|
func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"errors"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
"reflect"
|
|
8
9
|
"testing"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
@@ -222,15 +223,18 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
|
|
|
222
223
|
if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
|
|
223
224
|
t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
|
|
224
225
|
}
|
|
225
|
-
if fr.findCalls !=
|
|
226
|
+
if fr.findCalls != 2 || fh.buildCalls != 1 || fr.createCalls != 1 {
|
|
226
227
|
t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
|
|
227
228
|
}
|
|
228
|
-
if len(fr.findIDs) !=
|
|
229
|
-
t.Fatalf("FindTerminal IDs = %v, want [dead-term]", fr.findIDs)
|
|
229
|
+
if len(fr.findIDs) != 2 || fr.findIDs[0] != "dead-term" || fr.findIDs[1] != "dead-term" {
|
|
230
|
+
t.Fatalf("FindTerminal IDs = %v, want [dead-term dead-term]", fr.findIDs)
|
|
230
231
|
}
|
|
231
232
|
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
|
|
232
233
|
t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
|
|
233
234
|
}
|
|
235
|
+
if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
|
|
236
|
+
t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
|
|
237
|
+
}
|
|
234
238
|
for _, prompt := range fh.prompts {
|
|
235
239
|
if prompt != "work" {
|
|
236
240
|
t.Fatalf("same-visit relaunch prompt = %q, want standard prompt only", prompt)
|
|
@@ -261,7 +265,7 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
|
|
|
261
265
|
if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
|
|
262
266
|
t.Fatal(err)
|
|
263
267
|
}
|
|
264
|
-
if len(fh.prompts) != 1 || fh.prompts[0] != "
|
|
268
|
+
if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\ncustom instructions" {
|
|
265
269
|
t.Fatalf("initial prompt = %q", fh.prompts)
|
|
266
270
|
}
|
|
267
271
|
if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
|
|
@@ -298,7 +302,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
|
298
302
|
if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
|
|
299
303
|
t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
|
|
300
304
|
}
|
|
301
|
-
if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "
|
|
305
|
+
if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
|
|
302
306
|
t.Fatalf("live revisit prompt = %q", fr.sentTexts)
|
|
303
307
|
}
|
|
304
308
|
if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
|
|
@@ -311,6 +315,9 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
|
311
315
|
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
|
|
312
316
|
t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
|
|
313
317
|
}
|
|
318
|
+
if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
|
|
319
|
+
t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
|
|
320
|
+
}
|
|
314
321
|
}
|
|
315
322
|
|
|
316
323
|
func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
@@ -338,6 +345,10 @@ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
|
338
345
|
if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
|
|
339
346
|
t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
|
|
340
347
|
}
|
|
348
|
+
fh := a.Harness.(*runtimeTestHarness)
|
|
349
|
+
if len(fh.rendered) != 0 || fh.buildCalls != 0 {
|
|
350
|
+
t.Fatalf("same visit rendered=%v buildCalls=%d, want silence", fh.rendered, fh.buildCalls)
|
|
351
|
+
}
|
|
341
352
|
}
|
|
342
353
|
|
|
343
354
|
func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
|
|
@@ -487,8 +498,22 @@ type runtimeTestHarness struct {
|
|
|
487
498
|
buildCalls int
|
|
488
499
|
prompts []string
|
|
489
500
|
resumeIDs []string
|
|
501
|
+
rendered []harness.PromptKind
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
|
|
505
|
+
h.rendered = append(h.rendered, kind)
|
|
506
|
+
prompt := "work"
|
|
507
|
+
if kind == harness.PromptFeedback {
|
|
508
|
+
prompt = "feedback"
|
|
509
|
+
}
|
|
510
|
+
if nudge != "" {
|
|
511
|
+
prompt += "\n\n" + nudge
|
|
512
|
+
}
|
|
513
|
+
return prompt, nil
|
|
490
514
|
}
|
|
491
515
|
|
|
516
|
+
func (*runtimeTestHarness) SetupRepo(context.Context, string) error { return nil }
|
|
492
517
|
func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
493
518
|
func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
494
519
|
return harness.Session{}, false, nil
|
|
@@ -759,8 +759,8 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
|
|
|
759
759
|
return err
|
|
760
760
|
}
|
|
761
761
|
rm := &run.RunManager{Executor: engine, Runs: engine}
|
|
762
|
-
specsFor := func(w *workflow.Workflow
|
|
763
|
-
return goworkflows.
|
|
762
|
+
specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
763
|
+
return goworkflows.RenderMailboxSpecs(system, work, w)
|
|
764
764
|
}
|
|
765
765
|
return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
|
|
766
766
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package goworkflows
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestSharedReportContractFixtureRendersCommentValues(t *testing.T) {
|
|
12
|
+
b, err := os.ReadFile("../../../testdata/report-contract.json")
|
|
13
|
+
if err != nil {
|
|
14
|
+
t.Fatal(err)
|
|
15
|
+
}
|
|
16
|
+
var fixtures map[string]struct {
|
|
17
|
+
Envelope run.ReportRequest `json:"envelope"`
|
|
18
|
+
SummaryReport string `json:"summaryReport"`
|
|
19
|
+
FeedbackReport string `json:"feedbackReport"`
|
|
20
|
+
}
|
|
21
|
+
if err := json.Unmarshal(b, &fixtures); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
fixture := fixtures["work"]
|
|
25
|
+
if got := renderSummaryReport(fixture.Envelope.Report); got != fixture.SummaryReport {
|
|
26
|
+
t.Fatalf("summaryReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.SummaryReport)
|
|
27
|
+
}
|
|
28
|
+
if got := renderFeedbackReport(fixture.Envelope.Report); got != fixture.FeedbackReport {
|
|
29
|
+
t.Fatalf("feedbackReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.FeedbackReport)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -32,6 +32,8 @@ func newFakeHarness() *fakeHarness {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
|
|
36
|
+
|
|
35
37
|
func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
36
38
|
if !f.agents[agent] {
|
|
37
39
|
return errUnknownAgent
|
|
@@ -44,6 +46,14 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
|
|
|
44
46
|
return s, ok, nil
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
func (f *fakeHarness) RenderPrompt(_ harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
|
|
50
|
+
prompt := data.TaskSystem + ":" + data.Ticket + ":" + data.Mailbox
|
|
51
|
+
if nudge != "" {
|
|
52
|
+
prompt += "\n\n" + nudge
|
|
53
|
+
}
|
|
54
|
+
return prompt, nil
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
48
58
|
// The fake mirrors the required env contract; the real opencode harness
|
|
49
59
|
// builds the executable/args. NEXT_STEPS_JSON carries the legal targets
|