relay-flow 0.3.9-alpha → 0.3.11-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 +30 -34
- package/cmd/relay-flow/commands_test.go +449 -23
- package/cmd/relay-flow/main.go +207 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/examples/config-reference.yaml +23 -19
- package/examples/workflow-reference.yaml +3 -0
- package/internal/execution/goworkflows/activities.go +13 -22
- package/internal/execution/goworkflows/engine_test.go +17 -3
- package/internal/execution/goworkflows/fakes_test.go +20 -0
- package/internal/execution/goworkflows/interpreter.go +25 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/recovery_test.go +81 -1
- package/internal/execution/temporal/activities.go +13 -22
- package/internal/execution/temporal/compact_mailbox_test.go +56 -0
- package/internal/execution/temporal/interpreter.go +16 -3
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +23 -10
- package/internal/harness/opencode/opencode.go +22 -4
- package/internal/harness/opencode/opencode_test.go +41 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +2 -2
- package/internal/harness/pi/pi.go +21 -2
- package/internal/harness/pi/pi_test.go +7 -4
- package/internal/harness/pi/prompt_test.go +13 -5
- package/internal/harness/pi/task_env_test.go +18 -2
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +244 -15
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +67 -0
- package/internal/workflow/report.go +10 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package temporal
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
9
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
type compactMailboxRenderer struct{ *lagTaskSystem }
|
|
13
|
+
|
|
14
|
+
func (*compactMailboxRenderer) RenderText(_ task.TextKind, data task.TextData) (string, error) {
|
|
15
|
+
return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
|
|
16
|
+
"\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
|
|
17
|
+
"\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
|
|
21
|
+
wf := workflow.Workflow{Name: "test", Nodes: map[string]workflow.Node{
|
|
22
|
+
"coder": {Type: workflow.NodeAgent, Agent: "coder", Description: "Implement", OnSuccess: []workflow.Route{{Target: "end"}}},
|
|
23
|
+
}}
|
|
24
|
+
specs, err := RenderMailboxSpecs(&compactMailboxRenderer{&lagTaskSystem{}}, run.Work{
|
|
25
|
+
Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
|
|
26
|
+
}, &wf)
|
|
27
|
+
if err != nil {
|
|
28
|
+
t.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
if len(specs) != 1 {
|
|
31
|
+
t.Fatalf("compact mailbox descriptions = %+v", specs)
|
|
32
|
+
}
|
|
33
|
+
for _, required := range []string{"PAY-101 / coder — agent — coder", "Implement", "Success routes:\nend", "Failure routes:", workflow.ReportFormat} {
|
|
34
|
+
if !strings.Contains(specs[0].Description, required) {
|
|
35
|
+
t.Fatalf("mailbox description lacks %q: %q", required, specs[0].Description)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if strings.Contains(specs[0].Description, "Required report format:") {
|
|
39
|
+
t.Fatalf("generic instructions appended to Jira template: %q", specs[0].Description)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestConciseReportRendersOnlySelectedSummaryAndFeedback(t *testing.T) {
|
|
44
|
+
r := workflow.Report{
|
|
45
|
+
Summary: workflow.Summary{Completed: "done", Commits: workflow.None, NotCompleted: workflow.None,
|
|
46
|
+
IssuesDiscovered: workflow.None, Verification: workflow.None, Notes: workflow.None},
|
|
47
|
+
Feedback: workflow.Feedback{ReasonForNextStep: workflow.None, RequiredActions: "review it",
|
|
48
|
+
RelevantContext: workflow.None, ExpectedResult: workflow.None},
|
|
49
|
+
}
|
|
50
|
+
if got := renderSummaryReport(r); got != "done" {
|
|
51
|
+
t.Fatalf("summary comment = %q", got)
|
|
52
|
+
}
|
|
53
|
+
if got := renderFeedbackReport(r); got != "review it" {
|
|
54
|
+
t.Fatalf("feedback comment = %q", got)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -342,6 +342,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
342
342
|
state.upsertStep(startStep)
|
|
343
343
|
|
|
344
344
|
current := target
|
|
345
|
+
previousFeedback := ""
|
|
345
346
|
lastStepByNode := map[string]int64{}
|
|
346
347
|
lastDepthByNode := map[string]int{}
|
|
347
348
|
for current != "end" {
|
|
@@ -402,11 +403,12 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
402
403
|
spec := harness.LaunchSpec{
|
|
403
404
|
RunID: start.ID, NodeVisitID: visit, RepoName: start.Repo, RepoPath: start.RepoPath,
|
|
404
405
|
Workflow: wf.Name, Ticket: start.Ticket.Key, Node: current, NodeType: node.Type,
|
|
405
|
-
|
|
406
|
+
AutoReject: node.AutoReject,
|
|
407
|
+
Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
|
|
406
408
|
PromptData: harness.PromptData{
|
|
407
409
|
TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
|
|
408
410
|
Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
|
|
409
|
-
NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key,
|
|
411
|
+
NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key, PreviousFeedback: previousFeedback,
|
|
410
412
|
},
|
|
411
413
|
NextSteps: nextSteps,
|
|
412
414
|
}
|
|
@@ -515,7 +517,9 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
515
517
|
stepMessage := accepted.Report.Summary.Completed
|
|
516
518
|
if accepted.Report.Status == domainworkflow.OutcomeFailure {
|
|
517
519
|
stepStatus = run.StepFailed
|
|
518
|
-
|
|
520
|
+
if accepted.Report.Summary.IssuesDiscovered != domainworkflow.None {
|
|
521
|
+
stepMessage = accepted.Report.Summary.IssuesDiscovered
|
|
522
|
+
}
|
|
519
523
|
}
|
|
520
524
|
stepFinished := temporalworkflow.Now(ctx).UTC()
|
|
521
525
|
step.Status, step.FinishedAt, step.Message, step.Route = stepStatus, &stepFinished, stepMessage, accepted.Report.NextStep
|
|
@@ -568,6 +572,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
|
|
|
568
572
|
return err
|
|
569
573
|
}
|
|
570
574
|
applyRuntimePolicy(state, current, work.Runtime)
|
|
575
|
+
previousFeedback = accepted.Report.Feedback.RequiredActions
|
|
571
576
|
current = next
|
|
572
577
|
}
|
|
573
578
|
|
|
@@ -858,11 +863,19 @@ func applyRuntimePolicy(state *workflowState, node string, policy run.RuntimePol
|
|
|
858
863
|
}
|
|
859
864
|
|
|
860
865
|
func renderSummaryReport(r domainworkflow.Report) string {
|
|
866
|
+
if r.Summary.Commits == domainworkflow.None && r.Summary.NotCompleted == domainworkflow.None &&
|
|
867
|
+
r.Summary.IssuesDiscovered == domainworkflow.None && r.Summary.Verification == domainworkflow.None && r.Summary.Notes == domainworkflow.None {
|
|
868
|
+
return r.Summary.Completed
|
|
869
|
+
}
|
|
861
870
|
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",
|
|
862
871
|
r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
|
|
863
872
|
}
|
|
864
873
|
|
|
865
874
|
func renderFeedbackReport(r domainworkflow.Report) string {
|
|
875
|
+
if r.Summary.Commits == domainworkflow.None && r.Feedback.ReasonForNextStep == domainworkflow.None &&
|
|
876
|
+
r.Feedback.RelevantContext == domainworkflow.None && r.Feedback.ExpectedResult == domainworkflow.None {
|
|
877
|
+
return r.Feedback.RequiredActions
|
|
878
|
+
}
|
|
866
879
|
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",
|
|
867
880
|
r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
|
|
868
881
|
}
|
|
@@ -6,10 +6,12 @@ import (
|
|
|
6
6
|
"fmt"
|
|
7
7
|
"log/slog"
|
|
8
8
|
"sort"
|
|
9
|
-
"strings"
|
|
10
9
|
"time"
|
|
11
10
|
|
|
12
11
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/router"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
13
15
|
enumspb "go.temporal.io/api/enums/v1"
|
|
14
16
|
"go.temporal.io/api/serviceerror"
|
|
15
17
|
workflowpb "go.temporal.io/api/workflow/v1"
|
|
@@ -145,52 +147,82 @@ func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string
|
|
|
145
147
|
slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
|
|
146
148
|
continue
|
|
147
149
|
}
|
|
150
|
+
capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
|
|
151
|
+
if !ok {
|
|
152
|
+
slog.Warn("Temporal recovery skipping repository without ownership capabilities", "repo", rp.Name,
|
|
153
|
+
"outcome", "ownership-capability-missing")
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
148
156
|
tickets, err := rp.TaskSystem.Poll(ctx)
|
|
149
157
|
if err != nil {
|
|
150
158
|
return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
|
|
151
159
|
}
|
|
152
160
|
for _, ticket := range tickets {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
|
|
169
|
-
if visible[string(expectedID)] {
|
|
170
|
-
continue
|
|
161
|
+
// Projection reconciliation only considers claimed parents. Resolve
|
|
162
|
+
// through the same router as normal polling so ambiguous, unknown,
|
|
163
|
+
// and owner-mismatched claims cannot restore an execution.
|
|
164
|
+
if len(ticket.WorkflowClaims) != 1 {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
wf, err := router.ResolveWorkflow(rp, ticket)
|
|
168
|
+
if err != nil {
|
|
169
|
+
var ownerMismatch *router.ClaimOwnerMismatchError
|
|
170
|
+
if errors.As(err, &ownerMismatch) {
|
|
171
|
+
slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
172
|
+
"workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch")
|
|
173
|
+
} else {
|
|
174
|
+
slog.Warn("Temporal recovery skipped claimed ticket", "repo", rp.Name, "ticket", ticket.Key, "outcome", "invalid-claim", "error", err)
|
|
171
175
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
workflowName := wf.Name
|
|
179
|
+
var matched *repo.WorkflowBinding
|
|
180
|
+
for _, binding := range rp.Bindings() {
|
|
181
|
+
if binding.Workflow != nil && binding.Workflow.Name == workflowName {
|
|
182
|
+
candidate := binding
|
|
183
|
+
matched = &candidate
|
|
184
|
+
break
|
|
183
185
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
}
|
|
187
|
+
if matched == nil {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
// Existing Temporal executions are restored only after the same
|
|
191
|
+
// ownership gate used by normal claimed routing. Lifecycle
|
|
192
|
+
// filters remain intentionally bypassed.
|
|
193
|
+
if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), workflowName, matched.Workflow.TaskConfig); err != nil {
|
|
194
|
+
if errors.Is(err, task.ErrOwnershipMismatch) {
|
|
195
|
+
slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
|
|
196
|
+
"workflow", workflowName, "outcome", "claim-owner-mismatch", "error", err)
|
|
186
197
|
continue
|
|
187
198
|
}
|
|
188
|
-
|
|
199
|
+
return fmt.Errorf("poll repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
|
|
200
|
+
}
|
|
201
|
+
expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
|
|
202
|
+
if visible[string(expectedID)] {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
|
|
206
|
+
if err != nil {
|
|
207
|
+
var notFound *serviceerror.NotFound
|
|
208
|
+
if errors.As(err, ¬Found) {
|
|
209
|
+
// This is the documented claim-before-run gap. Record it
|
|
210
|
+
// for operators, but leave creation to normal polling after
|
|
211
|
+
// recovery; this path must never start a replacement.
|
|
212
|
+
slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
|
|
189
213
|
continue
|
|
190
214
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
215
|
+
return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
|
|
216
|
+
}
|
|
217
|
+
if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
|
|
218
|
+
info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
|
|
225
|
+
return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
|
|
194
226
|
}
|
|
195
227
|
}
|
|
196
228
|
}
|
|
@@ -24,20 +24,32 @@ const (
|
|
|
24
24
|
PromptFeedback PromptKind = "feedback"
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
+
// Jira prompt defaults keep each agent read limited to the relevant mailbox.
|
|
28
|
+
// A node that needs its parent's details can request the optional parent-read
|
|
29
|
+
// instruction in its node description.
|
|
30
|
+
const JiraInitialPrompt = `Read the description of your assigned Jira task:
|
|
31
|
+
acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
|
|
32
|
+
|
|
33
|
+
Follow the work instructions, valid routes, and report format in that description.`
|
|
34
|
+
|
|
35
|
+
const JiraFeedbackPrompt = `Continue {{node}} from the latest update on your assigned Jira task:
|
|
36
|
+
acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json`
|
|
37
|
+
|
|
27
38
|
// PromptData is the task-system-neutral data core supplies to the selected
|
|
28
39
|
// harness. Harness templates, including HITL instructions, are rendered only
|
|
29
40
|
// by the harness.
|
|
30
41
|
type PromptData struct {
|
|
31
|
-
TaskSystem
|
|
32
|
-
Ticket
|
|
33
|
-
Workflow
|
|
34
|
-
Repo
|
|
35
|
-
Node
|
|
36
|
-
NodeType
|
|
37
|
-
Agent
|
|
38
|
-
NodeDescription
|
|
39
|
-
NextSteps
|
|
40
|
-
Mailbox
|
|
42
|
+
TaskSystem string
|
|
43
|
+
Ticket string
|
|
44
|
+
Workflow string
|
|
45
|
+
Repo string
|
|
46
|
+
Node string
|
|
47
|
+
NodeType workflow.NodeType
|
|
48
|
+
Agent string
|
|
49
|
+
NodeDescription string
|
|
50
|
+
NextSteps string
|
|
51
|
+
Mailbox string
|
|
52
|
+
PreviousFeedback string
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
type LaunchSpec struct {
|
|
@@ -49,6 +61,7 @@ type LaunchSpec struct {
|
|
|
49
61
|
Ticket string
|
|
50
62
|
Node string
|
|
51
63
|
NodeType workflow.NodeType
|
|
64
|
+
AutoReject bool
|
|
52
65
|
Agent string
|
|
53
66
|
Title string
|
|
54
67
|
Prompt string
|
|
@@ -17,6 +17,7 @@ import (
|
|
|
17
17
|
"os/exec"
|
|
18
18
|
"path/filepath"
|
|
19
19
|
"regexp"
|
|
20
|
+
"strconv"
|
|
20
21
|
"strings"
|
|
21
22
|
|
|
22
23
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
@@ -136,23 +137,35 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
|
|
|
136
137
|
return harness.Session{}, false, nil
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
// RenderPrompt renders the selected session prompt
|
|
140
|
-
//
|
|
141
|
-
//
|
|
140
|
+
// RenderPrompt renders the selected session prompt and HITL instructions.
|
|
141
|
+
// Jira first launches omit the node nudge so the initial read stays limited
|
|
142
|
+
// to the assigned mailbox description.
|
|
142
143
|
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
143
144
|
var tmpl string
|
|
144
145
|
switch kind {
|
|
145
146
|
case harness.PromptInitial:
|
|
146
147
|
tmpl = h.templates.Initial
|
|
148
|
+
if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
|
|
149
|
+
tmpl = harness.JiraInitialPrompt
|
|
150
|
+
}
|
|
147
151
|
case harness.PromptFeedback:
|
|
148
152
|
tmpl = h.templates.Feedback
|
|
153
|
+
if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
|
|
154
|
+
tmpl = harness.JiraFeedbackPrompt
|
|
155
|
+
}
|
|
149
156
|
default:
|
|
150
157
|
return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
|
|
151
158
|
}
|
|
152
159
|
prompt := renderTemplate(tmpl, data)
|
|
153
|
-
if data.
|
|
160
|
+
if data.TaskSystem == "jira" && data.PreviousFeedback != "" {
|
|
161
|
+
prompt = appendPrompt(prompt, "Previous step feedback: "+data.PreviousFeedback)
|
|
162
|
+
}
|
|
163
|
+
if data.NodeType == workflow.NodeHITL && (data.TaskSystem != "jira" || h.templates.HITL != defaultHITLPrompt) {
|
|
154
164
|
prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
|
|
155
165
|
}
|
|
166
|
+
if kind == harness.PromptInitial && data.TaskSystem == "jira" {
|
|
167
|
+
return prompt, nil
|
|
168
|
+
}
|
|
156
169
|
return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
|
|
157
170
|
}
|
|
158
171
|
|
|
@@ -203,9 +216,14 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
|
|
|
203
216
|
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
204
217
|
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
205
218
|
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
219
|
+
"RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
|
|
206
220
|
} {
|
|
207
221
|
env[key] = value
|
|
208
222
|
}
|
|
223
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
224
|
+
// HITL policy is launch metadata, not part of the report wire object.
|
|
225
|
+
env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
|
|
226
|
+
}
|
|
209
227
|
args := []string{}
|
|
210
228
|
if spec.ResumeID != "" {
|
|
211
229
|
args = append(args, "--session", spec.ResumeID)
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.3.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.3.11-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -57,6 +57,22 @@ func TestBuildCommandArgv(t *testing.T) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
|
|
61
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
62
|
+
cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
|
|
63
|
+
NodeType: workflow.NodeHITL,
|
|
64
|
+
AutoReject: true,
|
|
65
|
+
Agent: "reviewer",
|
|
66
|
+
Prompt: "review the ticket",
|
|
67
|
+
})
|
|
68
|
+
if err != nil {
|
|
69
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
70
|
+
}
|
|
71
|
+
if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
|
|
72
|
+
t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
61
77
|
raw := config.RawValues{
|
|
62
78
|
"initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
|
|
@@ -89,6 +105,30 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
89
105
|
}
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
func TestJiraDefaultPromptsUseOnlyMailboxReadsAndSelectedFeedback(t *testing.T) {
|
|
109
|
+
h := opencode.New()
|
|
110
|
+
data := harness.PromptData{
|
|
111
|
+
TaskSystem: "jira", Ticket: "PAY-101", Mailbox: "PAY-234", Node: "coder", NodeType: workflow.NodeHITL,
|
|
112
|
+
}
|
|
113
|
+
initial, err := h.RenderPrompt(harness.PromptInitial, data, "Read the latest mailbox feedback.")
|
|
114
|
+
if err != nil {
|
|
115
|
+
t.Fatal(err)
|
|
116
|
+
}
|
|
117
|
+
wantInitial := "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
|
|
118
|
+
if initial != wantInitial {
|
|
119
|
+
t.Fatalf("Jira initial prompt = %q, want %q", initial, wantInitial)
|
|
120
|
+
}
|
|
121
|
+
data.PreviousFeedback = "Review found a missing test; add it."
|
|
122
|
+
continuation, err := h.RenderPrompt(harness.PromptFeedback, data, "")
|
|
123
|
+
if err != nil {
|
|
124
|
+
t.Fatal(err)
|
|
125
|
+
}
|
|
126
|
+
wantContinuation := "Continue coder from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nPrevious step feedback: Review found a missing test; add it."
|
|
127
|
+
if continuation != wantContinuation {
|
|
128
|
+
t.Fatalf("Jira continuation = %q, want %q", continuation, wantContinuation)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
92
132
|
func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
|
|
93
133
|
_, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
|
|
94
134
|
if err == nil || !strings.Contains(err.Error(), "unknown template variable {{unknown}}") {
|
|
@@ -51,7 +51,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
|
|
|
51
51
|
if err != nil {
|
|
52
52
|
t.Fatalf("BuildCommand: %v", err)
|
|
53
53
|
}
|
|
54
|
-
if len(cmd.Env) !=
|
|
55
|
-
t.Fatalf("Env = %#v, want only the
|
|
54
|
+
if len(cmd.Env) != 10 {
|
|
55
|
+
t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -13,11 +13,13 @@ import (
|
|
|
13
13
|
"os/exec"
|
|
14
14
|
"path/filepath"
|
|
15
15
|
"regexp"
|
|
16
|
+
"strconv"
|
|
16
17
|
"strings"
|
|
17
18
|
|
|
18
19
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
19
20
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
20
21
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
22
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
const (
|
|
@@ -148,7 +150,7 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
|
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
// RenderPrompt renders the selected initial or feedback template and the
|
|
151
|
-
// node's nudge template. Initial prompts use Pi's native prompt-template
|
|
153
|
+
// node's nudge template (except on a Jira first launch). Initial prompts use Pi's native prompt-template
|
|
152
154
|
// command syntax; feedback is sent to an existing session and must remain
|
|
153
155
|
// raw so Pi does not expand the full prompt template again. HITL approval is
|
|
154
156
|
// not encoded in the prompt; the Pi extension asks for approval through
|
|
@@ -158,8 +160,14 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
|
|
|
158
160
|
switch kind {
|
|
159
161
|
case harness.PromptInitial:
|
|
160
162
|
tmpl = h.templates.Initial
|
|
163
|
+
if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
|
|
164
|
+
tmpl = harness.JiraInitialPrompt
|
|
165
|
+
}
|
|
161
166
|
case harness.PromptFeedback:
|
|
162
167
|
tmpl = h.templates.Feedback
|
|
168
|
+
if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
|
|
169
|
+
tmpl = harness.JiraFeedbackPrompt
|
|
170
|
+
}
|
|
163
171
|
default:
|
|
164
172
|
return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
|
|
165
173
|
}
|
|
@@ -168,7 +176,13 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
|
|
|
168
176
|
return "", err
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
|
-
prompt :=
|
|
179
|
+
prompt := renderTemplate(tmpl, data)
|
|
180
|
+
if data.TaskSystem == "jira" && data.PreviousFeedback != "" {
|
|
181
|
+
prompt = appendPrompt(prompt, "Previous step feedback: "+data.PreviousFeedback)
|
|
182
|
+
}
|
|
183
|
+
if kind != harness.PromptInitial || data.TaskSystem != "jira" {
|
|
184
|
+
prompt = appendPrompt(prompt, renderTemplate(nudgeTemplate, data))
|
|
185
|
+
}
|
|
172
186
|
if kind == harness.PromptInitial {
|
|
173
187
|
return applyPromptTemplate(data.Agent, prompt), nil
|
|
174
188
|
}
|
|
@@ -211,9 +225,14 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
|
211
225
|
"RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
|
|
212
226
|
"RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
|
|
213
227
|
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
228
|
+
"RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
|
|
214
229
|
} {
|
|
215
230
|
env[key] = value
|
|
216
231
|
}
|
|
232
|
+
if spec.NodeType == workflow.NodeHITL {
|
|
233
|
+
// HITL policy is launch metadata, not part of the report wire object.
|
|
234
|
+
env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
|
|
235
|
+
}
|
|
217
236
|
args := []string{"--name", spec.Title}
|
|
218
237
|
if spec.Agent != "default" {
|
|
219
238
|
args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
|
|
@@ -42,6 +42,7 @@ func TestBuildCommandUsesStrictPiCLIContract(t *testing.T) {
|
|
|
42
42
|
"RELAY_FLOW_NODE_TYPE": string(base.NodeType),
|
|
43
43
|
"RELAY_FLOW_NUDGE_PROMPT": base.NudgePrompt,
|
|
44
44
|
"RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
|
|
45
|
+
"RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
tests := []struct {
|
|
@@ -248,13 +249,13 @@ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string)
|
|
|
248
249
|
t.Fatalf("read strict Pi capture: %v", err)
|
|
249
250
|
}
|
|
250
251
|
fields := strings.Split(string(data), "\x00")
|
|
251
|
-
if len(fields) <
|
|
252
|
+
if len(fields) < 13 || fields[len(fields)-1] != "" {
|
|
252
253
|
t.Fatalf("malformed strict Pi capture: %q", data)
|
|
253
254
|
}
|
|
254
255
|
fields = fields[:len(fields)-1]
|
|
255
256
|
capture := piCapture{
|
|
256
257
|
cwd: fields[0],
|
|
257
|
-
args: strings.Split(fields[
|
|
258
|
+
args: strings.Split(fields[11], "\x1f"),
|
|
258
259
|
env: map[string]string{
|
|
259
260
|
"RELAY_FLOW_HOME": fields[1],
|
|
260
261
|
"RELAY_FLOW_RUN_ID": fields[2],
|
|
@@ -265,6 +266,7 @@ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string)
|
|
|
265
266
|
"RELAY_FLOW_NODE_TYPE": fields[7],
|
|
266
267
|
"RELAY_FLOW_NUDGE_PROMPT": fields[8],
|
|
267
268
|
"RELAY_FLOW_NEXT_STEPS_JSON": fields[9],
|
|
269
|
+
"RELAY_FLOW_REPORT_FORMAT": fields[10],
|
|
268
270
|
},
|
|
269
271
|
}
|
|
270
272
|
return capture
|
|
@@ -325,11 +327,12 @@ fi
|
|
|
325
327
|
[ -n "${RELAY_FLOW_NODE:-}" ] || exit 3
|
|
326
328
|
[ -n "${RELAY_FLOW_NODE_TYPE:-}" ] || exit 3
|
|
327
329
|
[ -n "${RELAY_FLOW_NEXT_STEPS_JSON:-}" ] || exit 3
|
|
330
|
+
[ -n "${RELAY_FLOW_REPORT_FORMAT:-}" ] || exit 3
|
|
328
331
|
|
|
329
|
-
printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
|
|
332
|
+
printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
|
|
330
333
|
"$PWD" "$RELAY_FLOW_HOME" "$RELAY_FLOW_RUN_ID" "$RELAY_FLOW_WORKFLOW" \
|
|
331
334
|
"$RELAY_FLOW_REPO" "$RELAY_FLOW_TICKET" "$RELAY_FLOW_NODE" "$RELAY_FLOW_NODE_TYPE" \
|
|
332
|
-
"${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$original_args" > "$capture"
|
|
335
|
+
"${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$RELAY_FLOW_REPORT_FORMAT" "$original_args" > "$capture"
|
|
333
336
|
`
|
|
334
337
|
if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
|
|
335
338
|
t.Fatal(err)
|
|
@@ -29,7 +29,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
|
|
|
29
29
|
if err != nil {
|
|
30
30
|
t.Fatalf("RenderPrompt(initial): %v", err)
|
|
31
31
|
}
|
|
32
|
-
wantInitial := "
|
|
32
|
+
wantInitial := "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
|
|
33
33
|
if initial != wantInitial {
|
|
34
34
|
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
35
35
|
}
|
|
@@ -38,10 +38,18 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
|
|
|
38
38
|
if err != nil {
|
|
39
39
|
t.Fatalf("RenderPrompt(feedback): %v", err)
|
|
40
40
|
}
|
|
41
|
-
wantFeedback := "
|
|
41
|
+
wantFeedback := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
|
|
42
42
|
if feedback != wantFeedback {
|
|
43
43
|
t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
|
|
44
44
|
}
|
|
45
|
+
data.PreviousFeedback = "Add the missing test."
|
|
46
|
+
continued, err := h.RenderPrompt(harness.PromptFeedback, data, "")
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
if want := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nPrevious step feedback: Add the missing test."; continued != want {
|
|
51
|
+
t.Fatalf("Jira Pi continuation = %q, want %q", continued, want)
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
func TestPiRenderPromptHonorsNodeNudgeTimingInput(t *testing.T) {
|
|
@@ -107,11 +115,11 @@ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.
|
|
|
107
115
|
Mailbox: "PAY-234",
|
|
108
116
|
}
|
|
109
117
|
|
|
110
|
-
initial, err := h.RenderPrompt(harness.PromptInitial, data, "
|
|
118
|
+
initial, err := h.RenderPrompt(harness.PromptInitial, data, "Read the latest mailbox feedback for {{node}}.")
|
|
111
119
|
if err != nil {
|
|
112
120
|
t.Fatalf("RenderPrompt(initial): %v", err)
|
|
113
121
|
}
|
|
114
|
-
wantInitial := "/coder
|
|
122
|
+
wantInitial := "/coder Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
|
|
115
123
|
if initial != wantInitial {
|
|
116
124
|
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
117
125
|
}
|
|
@@ -120,7 +128,7 @@ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.
|
|
|
120
128
|
if err != nil {
|
|
121
129
|
t.Fatalf("RenderPrompt(feedback): %v", err)
|
|
122
130
|
}
|
|
123
|
-
wantFeedback := "
|
|
131
|
+
wantFeedback := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nfeedback nudge"
|
|
124
132
|
if feedback != wantFeedback {
|
|
125
133
|
t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
|
|
126
134
|
}
|
|
@@ -2,8 +2,24 @@ package pi
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
5
7
|
)
|
|
6
8
|
|
|
9
|
+
func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
|
|
10
|
+
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
11
|
+
spec := launchSpec(t)
|
|
12
|
+
spec.NodeType = workflow.NodeHITL
|
|
13
|
+
spec.AutoReject = true
|
|
14
|
+
cmd, err := (&Harness{}).BuildCommand(spec)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatalf("BuildCommand: %v", err)
|
|
17
|
+
}
|
|
18
|
+
if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
|
|
19
|
+
t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
// TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
|
|
8
24
|
// workspace environment reaches the runner command so agent task commands
|
|
9
25
|
// address the same workspace as relay-flow, and that relay-flow's own
|
|
@@ -45,7 +61,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
|
|
|
45
61
|
if err != nil {
|
|
46
62
|
t.Fatalf("BuildCommand: %v", err)
|
|
47
63
|
}
|
|
48
|
-
if len(cmd.Env) !=
|
|
49
|
-
t.Fatalf("Env = %#v, want only the
|
|
64
|
+
if len(cmd.Env) != 10 {
|
|
65
|
+
t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
|
|
50
66
|
}
|
|
51
67
|
}
|