relay-flow 0.3.10-alpha → 0.3.12-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 +10 -22
- package/cmd/relay-flow/commands_test.go +40 -2
- package/cmd/relay-flow/main.go +13 -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.go +1 -1
- package/internal/execution/goworkflows/engine_test.go +52 -3
- package/internal/execution/goworkflows/interpreter.go +24 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -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 +14 -2
- package/internal/execution/temporal/operations.go +1 -1
- package/internal/execution/temporal/operations_test.go +75 -0
- package/internal/harness/harness.go +22 -10
- package/internal/harness/opencode/opencode.go +17 -4
- package/internal/harness/opencode/opencode_test.go +25 -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 +15 -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 +2 -2
- package/internal/run/run.go +10 -0
- package/internal/server/api_test.go +31 -0
- package/internal/server/client.go +11 -1
- package/internal/server/fixture_test.go +4 -0
- package/internal/server/server.go +2 -0
- package/internal/task/jira/jira.go +16 -11
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/task.go +1 -0
- package/internal/workflow/report.go +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
|
|
|
28
28
|
**OpenCode**
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
opencode plugin relay-flow-plugin@0.3.
|
|
31
|
+
opencode plugin relay-flow-plugin@0.3.12-alpha
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
**Pi**
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
37
|
+
pi install npm:relay-flow-plugin@0.3.12-alpha
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Pi loads the package's `pi.ts` extension from its manifest. Do not add
|
|
@@ -164,7 +164,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
|
|
|
164
164
|
```json
|
|
165
165
|
{
|
|
166
166
|
"$schema": "https://opencode.ai/config.json",
|
|
167
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
167
|
+
"plugin": ["relay-flow-plugin@0.3.12-alpha"]
|
|
168
168
|
}
|
|
169
169
|
```
|
|
170
170
|
|
|
@@ -173,7 +173,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
|
|
|
173
173
|
```json
|
|
174
174
|
{
|
|
175
175
|
"$schema": "https://opencode.ai/tui.json",
|
|
176
|
-
"plugin": ["relay-flow-plugin@0.3.
|
|
176
|
+
"plugin": ["relay-flow-plugin@0.3.12-alpha"]
|
|
177
177
|
}
|
|
178
178
|
```
|
|
179
179
|
|
|
@@ -189,7 +189,7 @@ Pi plugin: install the same published package manually in Pi's global package
|
|
|
189
189
|
settings before starting a Pi harness session:
|
|
190
190
|
|
|
191
191
|
```sh
|
|
192
|
-
pi install npm:relay-flow-plugin@0.3.
|
|
192
|
+
pi install npm:relay-flow-plugin@0.3.12-alpha
|
|
193
193
|
```
|
|
194
194
|
|
|
195
195
|
Relay-flow does not install or configure the package automatically. Pi resolves
|
|
@@ -614,28 +614,16 @@ durable run waiting; Pi does not require an LLM Question tool for this step.
|
|
|
614
614
|
|
|
615
615
|
## Structured node report
|
|
616
616
|
|
|
617
|
-
Every visit (agent or HITL) submits one report with
|
|
617
|
+
Every visit (agent or HITL) submits one report with four fixed fields:
|
|
618
618
|
|
|
619
619
|
```
|
|
620
620
|
STATUS: success | failure
|
|
621
|
-
NEXT STEP: <one
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
COMPLETED: ...
|
|
625
|
-
COMMITS: <commit IDs or None>
|
|
626
|
-
NOT COMPLETED: ... | None
|
|
627
|
-
ISSUES DISCOVERED: ... | None
|
|
628
|
-
VERIFICATION: ...
|
|
629
|
-
NOTES: ... | None
|
|
630
|
-
|
|
631
|
-
FEEDBACK:
|
|
632
|
-
REASON FOR NEXT STEP: ...
|
|
633
|
-
REQUIRED ACTIONS: ...
|
|
634
|
-
RELEVANT CONTEXT: ...
|
|
635
|
-
EXPECTED RESULT: ...
|
|
621
|
+
NEXT STEP: <one valid route>
|
|
622
|
+
SUMMARY: <concise result>
|
|
623
|
+
FEEDBACK: <concise handoff, or None when NEXT STEP is end>
|
|
636
624
|
```
|
|
637
625
|
|
|
638
|
-
The
|
|
626
|
+
The report format is hardcoded, exposed as `{{report}}` in Jira mailbox templates and passed to the runtime plugin through `RELAY_FLOW_REPORT_FORMAT`; configurable templates do not change it. The plugin maps `SUMMARY` to `report.summary.completed` and `FEEDBACK` to `report.feedback.requiredActions`, filling the remaining internal JSON fields with `None`. Relay-flow validates the structured report and writes the current summary to its mailbox and only the selected feedback to the next mailbox. When `NEXT STEP` is `end`, `FEEDBACK` must be exactly `None` and no feedback comment is written.
|
|
639
627
|
|
|
640
628
|
The plugin delivers `{runId, node, reportId, report}` as one JSON object via `relay-flow report` stdin with the shared backoff (initial 2s, factor 2, jitter 0.2, max 5m) until acknowledged. It derives `reportId` from the harness session/message identity. Duplicate/stale reports are acked safely with no repeated graph effects. Invalid agent output is nudged; ordinary, missing, or empty HITL output stays silent, while partial report-shaped HITL output is corrected and a valid HITL report opens the native TUI approval dialog. Relay-flow HITL approval does not use OpenCode's Question tool.
|
|
641
629
|
|
|
@@ -4,6 +4,7 @@ import (
|
|
|
4
4
|
"bytes"
|
|
5
5
|
"context"
|
|
6
6
|
"database/sql"
|
|
7
|
+
"encoding/json"
|
|
7
8
|
"errors"
|
|
8
9
|
"fmt"
|
|
9
10
|
"io"
|
|
@@ -23,6 +24,7 @@ import (
|
|
|
23
24
|
|
|
24
25
|
"github.com/charmbracelet/huh"
|
|
25
26
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
27
|
+
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
26
28
|
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
27
29
|
runsvc "github.com/rajpopat27/relay-flow/internal/run"
|
|
28
30
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
@@ -276,6 +278,36 @@ func TestReportAckMatrix(t *testing.T) {
|
|
|
276
278
|
}
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
func TestReportValidationErrorIsStructuredOnStderr(t *testing.T) {
|
|
282
|
+
const message = "NEXT STEP is end, so FEEDBACK must be exactly None."
|
|
283
|
+
const valid = `{"runId":"run-1","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"done","commits":"None","notCompleted":"None","issuesDiscovered":"None","verification":"None","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
|
|
284
|
+
home := t.TempDir()
|
|
285
|
+
serveAck(t, home, runsvc.ReportAck{}, &runsvc.InvalidReportError{Reason: message})
|
|
286
|
+
reader, writer, err := os.Pipe()
|
|
287
|
+
if err != nil {
|
|
288
|
+
t.Fatal(err)
|
|
289
|
+
}
|
|
290
|
+
original := os.Stderr
|
|
291
|
+
os.Stderr = writer
|
|
292
|
+
code := cli(t, home, valid, "report")
|
|
293
|
+
os.Stderr = original
|
|
294
|
+
_ = writer.Close()
|
|
295
|
+
defer reader.Close()
|
|
296
|
+
output, err := io.ReadAll(reader)
|
|
297
|
+
if err != nil {
|
|
298
|
+
t.Fatal(err)
|
|
299
|
+
}
|
|
300
|
+
var response struct {
|
|
301
|
+
Error struct {
|
|
302
|
+
Code string `json:"code"`
|
|
303
|
+
Message string `json:"message"`
|
|
304
|
+
} `json:"error"`
|
|
305
|
+
}
|
|
306
|
+
if err := json.Unmarshal(output, &response); err != nil || code != exitFail || response.Error.Code != "invalidReport" || response.Error.Message != message {
|
|
307
|
+
t.Fatalf("CLI validation response: exit=%d stderr=%q parsed=%+v err=%v", code, output, response, err)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
279
311
|
func TestReportUnreachableServerExits1(t *testing.T) {
|
|
280
312
|
valid := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
|
|
281
313
|
if code := cli(t, t.TempDir(), valid, "report"); code != 1 {
|
|
@@ -386,6 +418,9 @@ func TestInitWritesHarnessPromptDefaults(t *testing.T) {
|
|
|
386
418
|
t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
|
|
387
419
|
}
|
|
388
420
|
}
|
|
421
|
+
if cfg.HarnessConfig["initial"] != harness.JiraInitialPrompt || cfg.HarnessConfig["feedback"] != harness.JiraFeedbackPrompt {
|
|
422
|
+
t.Fatalf("Jira prompt defaults = %#v", cfg.HarnessConfig)
|
|
423
|
+
}
|
|
389
424
|
}
|
|
390
425
|
|
|
391
426
|
func TestInitWritesTaskTextDefaults(t *testing.T) {
|
|
@@ -406,6 +441,9 @@ func TestInitWritesTaskTextDefaults(t *testing.T) {
|
|
|
406
441
|
t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
|
|
407
442
|
}
|
|
408
443
|
}
|
|
444
|
+
if !strings.Contains(templates["mailboxDescription"].(string), "{{report}}") {
|
|
445
|
+
t.Fatal("Jira mailbox description must expose the canonical report")
|
|
446
|
+
}
|
|
409
447
|
}
|
|
410
448
|
|
|
411
449
|
func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
|
|
@@ -480,7 +518,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
|
|
|
480
518
|
if !ok {
|
|
481
519
|
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
482
520
|
}
|
|
483
|
-
taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
|
|
521
|
+
taskTemplates["mailboxDescription"] = "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}"
|
|
484
522
|
if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
|
|
485
523
|
t.Fatal(err)
|
|
486
524
|
}
|
|
@@ -528,7 +566,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
|
|
|
528
566
|
if !ok {
|
|
529
567
|
t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
|
|
530
568
|
}
|
|
531
|
-
if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
|
|
569
|
+
if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}" {
|
|
532
570
|
t.Fatalf("forced init changed task text override: %#v", got)
|
|
533
571
|
}
|
|
534
572
|
for _, key := range []string{"summaryComment", "feedbackComment"} {
|
package/cmd/relay-flow/main.go
CHANGED
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
"bufio"
|
|
10
10
|
"context"
|
|
11
11
|
"encoding/json"
|
|
12
|
+
"errors"
|
|
12
13
|
"flag"
|
|
13
14
|
"fmt"
|
|
14
15
|
"io"
|
|
@@ -664,6 +665,10 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
|
|
|
664
665
|
fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
|
|
665
666
|
return exitFail
|
|
666
667
|
}
|
|
668
|
+
if names[0] == "jira" {
|
|
669
|
+
harnessDefaults["initial"] = harness.JiraInitialPrompt
|
|
670
|
+
harnessDefaults["feedback"] = harness.JiraFeedbackPrompt
|
|
671
|
+
}
|
|
667
672
|
if !*force || !configExists {
|
|
668
673
|
cfg.HarnessConfig = harnessDefaults
|
|
669
674
|
} else {
|
|
@@ -1142,7 +1147,14 @@ func cmdReport(c *server.Client, stdin io.Reader) int {
|
|
|
1142
1147
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
1143
1148
|
defer cancel()
|
|
1144
1149
|
if _, err := c.SubmitReport(ctx, req); err != nil {
|
|
1145
|
-
|
|
1150
|
+
var apiErr *server.APIError
|
|
1151
|
+
if errors.As(err, &apiErr) && apiErr.Code == "invalidReport" {
|
|
1152
|
+
_ = json.NewEncoder(os.Stderr).Encode(map[string]any{
|
|
1153
|
+
"error": map[string]string{"code": apiErr.Code, "message": apiErr.Message},
|
|
1154
|
+
})
|
|
1155
|
+
} else {
|
|
1156
|
+
fmt.Fprintln(os.Stderr, err)
|
|
1157
|
+
}
|
|
1146
1158
|
return exitFail
|
|
1147
1159
|
}
|
|
1148
1160
|
return exitOK
|
|
@@ -42,21 +42,19 @@ taskConfig:
|
|
|
42
42
|
# parentStatus: In Progress
|
|
43
43
|
# taskStatus: In Progress
|
|
44
44
|
|
|
45
|
-
#
|
|
46
|
-
# relay-flow and is not changed by these templates.
|
|
45
|
+
# Jira mailbox descriptions include the canonical report through {{report}}.
|
|
47
46
|
templates:
|
|
48
47
|
mailboxDescription: |-
|
|
49
|
-
|
|
50
|
-
Workflow: {{workflow}}
|
|
51
|
-
Node: {{node}}
|
|
52
|
-
Node type: {{nodeType}}
|
|
53
|
-
Agent: {{agent}}
|
|
54
|
-
Mailbox: {{mailbox}}
|
|
55
|
-
|
|
56
|
-
Node work:
|
|
48
|
+
{{ticket}} / {{node}} — {{nodeType}} — {{agent}}
|
|
57
49
|
{{nodeDescription}}
|
|
58
50
|
|
|
59
|
-
|
|
51
|
+
Success routes:
|
|
52
|
+
{{successRoutes}}
|
|
53
|
+
|
|
54
|
+
Failure routes:
|
|
55
|
+
{{failureRoutes}}
|
|
56
|
+
|
|
57
|
+
{{report}}
|
|
60
58
|
|
|
61
59
|
summaryComment: |-
|
|
62
60
|
Summary for {{node}}
|
|
@@ -80,14 +78,19 @@ harnessPlugin: opencode
|
|
|
80
78
|
harnessConfig:
|
|
81
79
|
# OpenCode supports initial, feedback, and hitl templates.
|
|
82
80
|
initial: |-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
Read the description of your assigned Jira task:
|
|
82
|
+
acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
|
|
85
83
|
|
|
86
|
-
|
|
84
|
+
Follow the work instructions, valid routes, and report format in that description.
|
|
87
85
|
|
|
88
86
|
feedback: |-
|
|
89
|
-
|
|
87
|
+
Continue {{node}} from the latest update on your assigned Jira task:
|
|
88
|
+
acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
|
|
90
89
|
|
|
90
|
+
# A node needing parent details can request this additional instruction
|
|
91
|
+
# in its node description (it is not included by default):
|
|
92
|
+
# Read the parent Jira ticket for this step:
|
|
93
|
+
# acli jira workitem view "{{ticket}}" --fields "summary,description" --json
|
|
91
94
|
hitl: |-
|
|
92
95
|
Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.
|
|
93
96
|
|
|
@@ -135,12 +138,13 @@ repos:
|
|
|
135
138
|
# harnessPlugin: pi
|
|
136
139
|
# harnessConfig:
|
|
137
140
|
# initial: |
|
|
138
|
-
#
|
|
139
|
-
#
|
|
141
|
+
# Read the description of your assigned Jira task:
|
|
142
|
+
# acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
|
|
140
143
|
#
|
|
141
|
-
#
|
|
144
|
+
# Follow the work instructions, valid routes, and report format in that description.
|
|
142
145
|
# feedback: |
|
|
143
|
-
#
|
|
146
|
+
# Continue {{node}} from the latest update on your assigned Jira task:
|
|
147
|
+
# acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
|
|
144
148
|
#
|
|
145
149
|
# Pi accepts only initial and feedback harness templates. Pi workflow nodes
|
|
146
150
|
# may use agent: default or a native project prompt template in
|
|
@@ -56,6 +56,9 @@ nodes:
|
|
|
56
56
|
type: agent
|
|
57
57
|
agent: build
|
|
58
58
|
description: |
|
|
59
|
+
Read the parent Jira ticket for this step:
|
|
60
|
+
acli jira workitem view "{{ticket}}" --fields "summary,description" --json
|
|
61
|
+
|
|
59
62
|
Implement the parent ticket in the ticket worktree.
|
|
60
63
|
Run the relevant tests and verification commands.
|
|
61
64
|
nudgePrompt: |
|
|
@@ -80,7 +80,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
|
|
|
80
80
|
if err != nil {
|
|
81
81
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
84
|
+
specs[i].Description = custom
|
|
85
|
+
} else {
|
|
86
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
87
|
+
}
|
|
84
88
|
}
|
|
85
89
|
return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
|
|
86
90
|
}
|
|
@@ -488,26 +492,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
|
|
|
488
492
|
var b strings.Builder
|
|
489
493
|
b.WriteString(`Required report format:
|
|
490
494
|
|
|
491
|
-
|
|
492
|
-
NEXT STEP: <one valid node name>
|
|
493
|
-
|
|
494
|
-
SUMMARY:
|
|
495
|
-
COMPLETED:
|
|
496
|
-
COMMITS:
|
|
497
|
-
NOT COMPLETED:
|
|
498
|
-
ISSUES DISCOVERED:
|
|
499
|
-
VERIFICATION:
|
|
500
|
-
NOTES:
|
|
501
|
-
|
|
502
|
-
FEEDBACK:
|
|
503
|
-
REASON FOR NEXT STEP:
|
|
504
|
-
REQUIRED ACTIONS:
|
|
505
|
-
RELEVANT CONTEXT:
|
|
506
|
-
EXPECTED RESULT:
|
|
495
|
+
` + workflow.ReportFormat + `
|
|
507
496
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
|
|
497
|
+
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
|
|
511
498
|
writeRoutes := func(label string, routes []workflow.Route) {
|
|
512
499
|
if len(routes) == 0 {
|
|
513
500
|
return
|
|
@@ -535,7 +522,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
|
|
|
535
522
|
Agent: n.Agent, NodeDescription: n.Description,
|
|
536
523
|
NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
|
|
537
524
|
SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
|
|
538
|
-
Mailbox: ticketKey + ":" + name,
|
|
525
|
+
Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
|
|
539
526
|
},
|
|
540
527
|
}
|
|
541
528
|
}
|
|
@@ -569,7 +556,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
|
|
|
569
556
|
if err != nil {
|
|
570
557
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
571
558
|
}
|
|
572
|
-
|
|
559
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
560
|
+
specs[i].Description = custom
|
|
561
|
+
} else {
|
|
562
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
563
|
+
}
|
|
573
564
|
}
|
|
574
565
|
return specs, nil
|
|
575
566
|
}
|
|
@@ -394,7 +394,7 @@ func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.R
|
|
|
394
394
|
}
|
|
395
395
|
if err := wf.ValidateReport(req.Node, req.Report); err != nil {
|
|
396
396
|
slog.Info("report validation failed", append(attrs, "reason", err.Error())...)
|
|
397
|
-
return run.ReportAck{
|
|
397
|
+
return run.ReportAck{}, &run.InvalidReportError{Reason: err.Error()}
|
|
398
398
|
}
|
|
399
399
|
signal := reportSignal{
|
|
400
400
|
ReportID: req.ReportID, Node: req.Node,
|
|
@@ -3,6 +3,7 @@ package goworkflows_test
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"database/sql"
|
|
6
|
+
"errors"
|
|
6
7
|
"path/filepath"
|
|
7
8
|
"strings"
|
|
8
9
|
"testing"
|
|
@@ -72,13 +73,16 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
|
|
|
72
73
|
t.Fatal(err)
|
|
73
74
|
}
|
|
74
75
|
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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
|
|
76
|
+
if taskSystem == "jira" {
|
|
77
|
+
want = "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."
|
|
78
|
+
}
|
|
75
79
|
if prompt != want {
|
|
76
80
|
t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
|
|
77
81
|
}
|
|
78
|
-
if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
|
|
82
|
+
if taskSystem != "jira" && (strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask")) {
|
|
79
83
|
t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
|
|
80
84
|
}
|
|
81
|
-
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
85
|
+
if strings.Contains(prompt, "STATUS:") || (taskSystem != "jira" && strings.Contains(prompt, node.Description)) {
|
|
82
86
|
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
83
87
|
}
|
|
84
88
|
}
|
|
@@ -291,6 +295,40 @@ func TestSerialGraphOneNodeAtATime(t *testing.T) {
|
|
|
291
295
|
})
|
|
292
296
|
}
|
|
293
297
|
|
|
298
|
+
func TestInvalidReportIsPermanentAndDoesNotSignal(t *testing.T) {
|
|
299
|
+
log := newEventLog()
|
|
300
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
301
|
+
Repos: repoRegistryWith("payments", newFakeTaskSystem(log)),
|
|
302
|
+
Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
303
|
+
})
|
|
304
|
+
rid, err := startRun(engine, linearWorkflow(false))
|
|
305
|
+
if err != nil {
|
|
306
|
+
t.Fatal(err)
|
|
307
|
+
}
|
|
308
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
309
|
+
r, err := engine.GetRun(context.Background(), rid)
|
|
310
|
+
return err == nil && r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
311
|
+
})
|
|
312
|
+
before, _ := engine.GetRun(context.Background(), rid)
|
|
313
|
+
invalid := successReport("end")
|
|
314
|
+
invalid.Feedback.RequiredActions = "needs work"
|
|
315
|
+
ack, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", invalid))
|
|
316
|
+
if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted {
|
|
317
|
+
t.Fatalf("invalid report: ack=%+v err=%v", ack, err)
|
|
318
|
+
}
|
|
319
|
+
if want := `report selects "end": every feedback field must be "None" because end has no mailbox`; err.Error() != want {
|
|
320
|
+
t.Fatalf("validation message = %q, want %q", err, want)
|
|
321
|
+
}
|
|
322
|
+
after, err := engine.GetRun(context.Background(), rid)
|
|
323
|
+
if err != nil || after.CurrentNodeVisitID != before.CurrentNodeVisitID {
|
|
324
|
+
t.Fatalf("invalid report advanced visit: before=%+v after=%+v err=%v", before, after, err)
|
|
325
|
+
}
|
|
326
|
+
ack, err = engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end")))
|
|
327
|
+
if err != nil || !ack.Accepted {
|
|
328
|
+
t.Fatalf("corrected report: ack=%+v err=%v", ack, err)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
294
332
|
func TestRevisitCreatesNewVisit(t *testing.T) {
|
|
295
333
|
log := newEventLog()
|
|
296
334
|
sys := newFakeTaskSystem(log)
|
|
@@ -467,6 +505,7 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
467
505
|
log := newEventLog()
|
|
468
506
|
sys := newFakeTaskSystem(log)
|
|
469
507
|
fr := newFakeRunner(log)
|
|
508
|
+
fh := newFakeHarness(log)
|
|
470
509
|
wf := workflow.Workflow{
|
|
471
510
|
Name: "reviewFlow", Repos: []string{"payments"},
|
|
472
511
|
Nodes: map[string]workflow.Node{
|
|
@@ -485,7 +524,7 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
485
524
|
},
|
|
486
525
|
}
|
|
487
526
|
engine := newEngine(t, goworkflows.Dependencies{
|
|
488
|
-
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness:
|
|
527
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
489
528
|
})
|
|
490
529
|
rid, _ := startRun(engine, wf)
|
|
491
530
|
waitFor(t, 10*time.Second, func() bool {
|
|
@@ -510,6 +549,16 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
510
549
|
t.Fatalf("HITL status was not set before terminal start; events=%v", events)
|
|
511
550
|
}
|
|
512
551
|
|
|
552
|
+
var sawReviewFeedback bool
|
|
553
|
+
for _, call := range fh.promptCalls() {
|
|
554
|
+
if call.Data.Node == "review" && call.Data.PreviousFeedback == "review it" {
|
|
555
|
+
sawReviewFeedback = true
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if !sawReviewFeedback {
|
|
559
|
+
t.Fatalf("next node prompt did not receive selected feedback: %+v", fh.promptCalls())
|
|
560
|
+
}
|
|
561
|
+
|
|
513
562
|
// Exact cross-primitive order, observed through the fake-adapter and fake-
|
|
514
563
|
// runner call logs (the settled observation seam):
|
|
515
564
|
// summary(current) -> feedback(selected next) -> CompleteMailbox(current)
|
|
@@ -205,6 +205,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
current := target
|
|
208
|
+
previousFeedback := ""
|
|
208
209
|
seenReportIDs := map[string]bool{}
|
|
209
210
|
lastStepByNode := map[string]int64{}
|
|
210
211
|
lastDepthByNode := map[string]int{}
|
|
@@ -297,16 +298,17 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
297
298
|
Title: title,
|
|
298
299
|
NudgePrompt: node.NudgePrompt,
|
|
299
300
|
PromptData: harness.PromptData{
|
|
300
|
-
TaskSystem:
|
|
301
|
-
Ticket:
|
|
302
|
-
Workflow:
|
|
303
|
-
Repo:
|
|
304
|
-
Node:
|
|
305
|
-
NodeType:
|
|
306
|
-
Agent:
|
|
307
|
-
NodeDescription:
|
|
308
|
-
NextSteps:
|
|
309
|
-
Mailbox:
|
|
301
|
+
TaskSystem: a.TaskSystem,
|
|
302
|
+
Ticket: start.Ticket.Key,
|
|
303
|
+
Workflow: wf.Name,
|
|
304
|
+
Repo: start.Repo,
|
|
305
|
+
Node: current,
|
|
306
|
+
NodeType: node.Type,
|
|
307
|
+
Agent: node.Agent,
|
|
308
|
+
NodeDescription: node.Description,
|
|
309
|
+
NextSteps: nextStepsText(nextSteps),
|
|
310
|
+
Mailbox: mb.Key,
|
|
311
|
+
PreviousFeedback: previousFeedback,
|
|
310
312
|
},
|
|
311
313
|
NextSteps: nextSteps,
|
|
312
314
|
}
|
|
@@ -395,7 +397,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
395
397
|
stepMessage := report.Summary.Completed
|
|
396
398
|
if report.Status == workflow.OutcomeFailure {
|
|
397
399
|
stepStatus = run.StepFailed
|
|
398
|
-
|
|
400
|
+
if report.Summary.IssuesDiscovered != workflow.None {
|
|
401
|
+
stepMessage = report.Summary.IssuesDiscovered
|
|
402
|
+
}
|
|
399
403
|
}
|
|
400
404
|
stepFinished := goworkflow.Now(ctx).UTC()
|
|
401
405
|
if _, err := retryLoop(ctx, start.ID, a, work, current,
|
|
@@ -465,6 +469,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
465
469
|
return err
|
|
466
470
|
}
|
|
467
471
|
|
|
472
|
+
previousFeedback = report.Feedback.RequiredActions
|
|
468
473
|
current = next
|
|
469
474
|
}
|
|
470
475
|
|
|
@@ -761,11 +766,19 @@ func nextStepsText(routes []workflow.Route) string {
|
|
|
761
766
|
}
|
|
762
767
|
|
|
763
768
|
func renderSummaryReport(r workflow.Report) string {
|
|
769
|
+
if r.Summary.Commits == workflow.None && r.Summary.NotCompleted == workflow.None &&
|
|
770
|
+
r.Summary.IssuesDiscovered == workflow.None && r.Summary.Verification == workflow.None && r.Summary.Notes == workflow.None {
|
|
771
|
+
return r.Summary.Completed
|
|
772
|
+
}
|
|
764
773
|
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",
|
|
765
774
|
r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
|
|
766
775
|
}
|
|
767
776
|
|
|
768
777
|
func renderFeedbackReport(r workflow.Report) string {
|
|
778
|
+
if r.Summary.Commits == workflow.None && r.Feedback.ReasonForNextStep == workflow.None &&
|
|
779
|
+
r.Feedback.RelevantContext == workflow.None && r.Feedback.ExpectedResult == workflow.None {
|
|
780
|
+
return r.Feedback.RequiredActions
|
|
781
|
+
}
|
|
769
782
|
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",
|
|
770
783
|
r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
|
|
771
784
|
}
|
|
@@ -41,6 +41,33 @@ func threeNodeWorkflow() workflow.Workflow {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
|
|
45
|
+
wf := threeNodeWorkflow()
|
|
46
|
+
sys := newFakeTaskSystem(newEventLog())
|
|
47
|
+
sys.renderText = func(_ task.TextKind, data task.TextData) (string, error) {
|
|
48
|
+
return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
|
|
49
|
+
"\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
|
|
50
|
+
"\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
|
|
51
|
+
}
|
|
52
|
+
specs, err := goworkflows.RenderMailboxSpecs(sys, run.Work{
|
|
53
|
+
Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
|
|
54
|
+
}, &wf)
|
|
55
|
+
if err != nil {
|
|
56
|
+
t.Fatal(err)
|
|
57
|
+
}
|
|
58
|
+
for _, spec := range specs {
|
|
59
|
+
for _, required := range []string{"PAY-101 / " + spec.Node, wf.Nodes[spec.Node].Description,
|
|
60
|
+
"Success routes:", "Failure routes:", " — when: ", workflow.ReportFormat} {
|
|
61
|
+
if !strings.Contains(spec.Description, required) {
|
|
62
|
+
t.Fatalf("mailbox %s description lacks %q: %q", spec.Node, required, spec.Description)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if strings.Contains(spec.Description, "Required report format:") {
|
|
66
|
+
t.Fatalf("mailbox %s appended generic instructions: %q", spec.Node, spec.Description)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
44
71
|
func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
|
|
45
72
|
log := newEventLog()
|
|
46
73
|
sys := newFakeTaskSystem(log)
|
|
@@ -107,7 +134,7 @@ func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
|
|
|
107
134
|
t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
|
|
108
135
|
}
|
|
109
136
|
}
|
|
110
|
-
for _, required := range []string{"Required report format:", "STATUS:", "
|
|
137
|
+
for _, required := range []string{"Required report format:", "STATUS:", "NEXT STEP:", "SUMMARY:", "FEEDBACK:"} {
|
|
111
138
|
if !strings.Contains(d, required) {
|
|
112
139
|
t.Fatalf("%s description lacks %q: %q", node, required, d)
|
|
113
140
|
}
|
|
@@ -85,7 +85,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
|
|
|
85
85
|
if err != nil {
|
|
86
86
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
89
|
+
specs[i].Description = custom
|
|
90
|
+
} else {
|
|
91
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
92
|
+
}
|
|
89
93
|
}
|
|
90
94
|
return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
|
|
91
95
|
}
|
|
@@ -486,26 +490,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
|
|
|
486
490
|
var b strings.Builder
|
|
487
491
|
b.WriteString(`Required report format:
|
|
488
492
|
|
|
489
|
-
|
|
490
|
-
NEXT STEP: <one valid node name>
|
|
491
|
-
|
|
492
|
-
SUMMARY:
|
|
493
|
-
COMPLETED:
|
|
494
|
-
COMMITS:
|
|
495
|
-
NOT COMPLETED:
|
|
496
|
-
ISSUES DISCOVERED:
|
|
497
|
-
VERIFICATION:
|
|
498
|
-
NOTES:
|
|
499
|
-
|
|
500
|
-
FEEDBACK:
|
|
501
|
-
REASON FOR NEXT STEP:
|
|
502
|
-
REQUIRED ACTIONS:
|
|
503
|
-
RELEVANT CONTEXT:
|
|
504
|
-
EXPECTED RESULT:
|
|
493
|
+
` + workflow.ReportFormat + `
|
|
505
494
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
|
|
495
|
+
Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
|
|
509
496
|
writeRoutes := func(label string, routes []workflow.Route) {
|
|
510
497
|
if len(routes) == 0 {
|
|
511
498
|
return
|
|
@@ -533,7 +520,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
|
|
|
533
520
|
Agent: n.Agent, NodeDescription: n.Description,
|
|
534
521
|
NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
|
|
535
522
|
SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
|
|
536
|
-
Mailbox: ticketKey + ":" + name,
|
|
523
|
+
Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
|
|
537
524
|
},
|
|
538
525
|
}
|
|
539
526
|
}
|
|
@@ -567,7 +554,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
|
|
|
567
554
|
if err != nil {
|
|
568
555
|
return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
|
|
569
556
|
}
|
|
570
|
-
|
|
557
|
+
if data.Report != "" && strings.Contains(custom, data.Report) {
|
|
558
|
+
specs[i].Description = custom
|
|
559
|
+
} else {
|
|
560
|
+
specs[i].Description = appendText(custom, specs[i].Description)
|
|
561
|
+
}
|
|
571
562
|
}
|
|
572
563
|
return specs, nil
|
|
573
564
|
}
|