relay-flow 0.2.1-alpha → 0.2.3-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +193 -25
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +79 -1
  4. package/cmd/relay-flow/main.go +50 -1
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +16 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +124 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +20 -0
  16. package/internal/execution/goworkflows/engine_test.go +33 -15
  17. package/internal/execution/goworkflows/fakes_test.go +84 -10
  18. package/internal/execution/goworkflows/interpreter.go +84 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  21. package/internal/execution/goworkflows/projection.go +52 -11
  22. package/internal/execution/goworkflows/recovery_test.go +131 -2
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/harness/contract_test.go +10 -0
  25. package/internal/harness/factory.go +25 -3
  26. package/internal/harness/harness.go +30 -4
  27. package/internal/harness/opencode/opencode.go +125 -10
  28. package/internal/harness/opencode/opencode_test.go +238 -0
  29. package/internal/harness/opencode/repo_setup.go +401 -0
  30. package/internal/harness/plugin_selection_test.go +5 -5
  31. package/internal/identity/identity.go +28 -1
  32. package/internal/identity/identity_test.go +40 -0
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/service.go +10 -0
  35. package/internal/repo/service_test.go +51 -2
  36. package/internal/run/manager.go +193 -25
  37. package/internal/run/run.go +30 -10
  38. package/internal/run/run_manager_test.go +100 -0
  39. package/internal/server/api_test.go +54 -0
  40. package/internal/server/client.go +11 -0
  41. package/internal/server/fixture_test.go +18 -0
  42. package/internal/server/server.go +16 -0
  43. package/internal/task/beads/bdcli/bdcli.go +323 -0
  44. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  45. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  46. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  47. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  48. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  49. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  50. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  51. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  52. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  56. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  57. package/internal/task/beads/beads.go +856 -0
  58. package/internal/task/beads/beads_test.go +609 -0
  59. package/internal/task/beads/comments_test.go +242 -0
  60. package/internal/task/beads/config_compatibility_test.go +163 -0
  61. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  62. package/internal/task/beads/repo_composition_test.go +232 -0
  63. package/internal/task/beads/runtime_config_test.go +81 -0
  64. package/internal/task/beads/status_compatibility_test.go +276 -0
  65. package/internal/task/beads/status_test.go +257 -0
  66. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  67. package/internal/task/beads/validation_test.go +110 -0
  68. package/internal/task/contract_test.go +10 -0
  69. package/internal/task/factory.go +37 -4
  70. package/internal/task/jira/auth.go +27 -1
  71. package/internal/task/jira/auth_test.go +54 -1
  72. package/internal/task/jira/filters_test.go +60 -0
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +187 -29
  75. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  76. package/internal/task/jira/rest/adf.go +119 -82
  77. package/internal/task/jira/rest/adf_test.go +60 -0
  78. package/internal/task/jira/rest/client_test.go +1 -1
  79. package/internal/task/jira/templates_test.go +118 -0
  80. package/internal/task/jira/transition_defaults_test.go +47 -2
  81. package/internal/task/task.go +40 -0
  82. package/internal/workflow/report_test.go +45 -0
  83. package/package.json +1 -1
@@ -2,109 +2,146 @@ package rest
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
- "regexp"
6
5
  "strings"
6
+
7
+ "github.com/yuin/goldmark"
8
+ "github.com/yuin/goldmark/ast"
9
+ "github.com/yuin/goldmark/text"
7
10
  )
8
11
 
9
- var orderedItem = regexp.MustCompile(`^\d+\.\s+`)
12
+ var markdown = goldmark.New()
10
13
 
11
- func ADF(text string) map[string]any {
12
- lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
13
- content := make([]any, 0)
14
- for i := 0; i < len(lines); {
15
- line := strings.TrimSpace(lines[i])
16
- if line == "" {
17
- i++
18
- continue
19
- }
20
- if strings.HasPrefix(line, "```") {
21
- i++
22
- code := make([]string, 0)
23
- for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") {
24
- code = append(code, lines[i])
25
- i++
26
- }
27
- if i < len(lines) {
28
- i++
29
- }
30
- content = append(content, map[string]any{"type": "codeBlock", "content": []any{textNode(strings.Join(code, "\n"))}})
31
- continue
32
- }
33
- if strings.HasPrefix(line, "- ") || orderedItem.MatchString(line) {
34
- ordered := orderedItem.MatchString(line)
35
- items := make([]any, 0)
36
- for i < len(lines) {
37
- item := strings.TrimSpace(lines[i])
38
- if ordered {
39
- if !orderedItem.MatchString(item) {
40
- break
41
- }
42
- item = orderedItem.ReplaceAllString(item, "")
43
- } else {
44
- if !strings.HasPrefix(item, "- ") {
45
- break
46
- }
47
- item = strings.TrimPrefix(item, "- ")
48
- }
49
- items = append(items, map[string]any{
50
- "type": "listItem",
51
- "content": []any{paragraph(item)},
52
- })
53
- i++
54
- }
55
- kind := "bulletList"
56
- if ordered {
57
- kind = "orderedList"
58
- }
59
- content = append(content, map[string]any{"type": kind, "content": items})
60
- continue
61
- }
62
- if heading(line) {
14
+ // ADF converts explicit Markdown syntax to Atlassian Document Format.
15
+ func ADF(value string) map[string]any {
16
+ source := []byte(strings.ReplaceAll(value, "\r\n", "\n"))
17
+ document := markdown.Parser().Parse(text.NewReader(source))
18
+ content := blockContent(document, source)
19
+ if len(content) == 0 {
20
+ content = []any{paragraph(nil)}
21
+ }
22
+ return map[string]any{"type": "doc", "version": 1, "content": content}
23
+ }
24
+
25
+ func blockContent(parent ast.Node, source []byte) []any {
26
+ content := make([]any, 0, parent.ChildCount())
27
+ for node := parent.FirstChild(); node != nil; node = node.NextSibling() {
28
+ switch node := node.(type) {
29
+ case *ast.Heading:
63
30
  content = append(content, map[string]any{
64
31
  "type": "heading",
65
- "attrs": map[string]any{"level": 3},
66
- "content": []any{textNode(strings.TrimSuffix(line, ":"))},
32
+ "attrs": map[string]any{"level": node.Level},
33
+ "content": inlineContent(node, source, nil),
67
34
  })
68
- i++
69
- continue
35
+ case *ast.Paragraph, *ast.TextBlock:
36
+ content = append(content, paragraph(inlineContent(node, source, nil)))
37
+ case *ast.List:
38
+ content = append(content, list(node, source))
39
+ case *ast.FencedCodeBlock:
40
+ content = append(content, codeBlock(node.Text(source), node.Language(source)))
41
+ case *ast.CodeBlock:
42
+ content = append(content, codeBlock(node.Text(source), nil))
43
+ case *ast.HTMLBlock:
44
+ content = append(content, paragraph([]any{textNode(string(node.Text(source)), nil)}))
45
+ default:
46
+ content = append(content, blockContent(node, source)...)
70
47
  }
71
- content = append(content, paragraph(line))
72
- i++
73
- }
74
- if len(content) == 0 {
75
- content = append(content, paragraph(""))
76
48
  }
77
- return map[string]any{"type": "doc", "version": 1, "content": content}
49
+ return content
78
50
  }
79
51
 
80
- func heading(line string) bool {
81
- if len(line) > 80 {
82
- return false
52
+ func list(value *ast.List, source []byte) map[string]any {
53
+ items := make([]any, 0, value.ChildCount())
54
+ for child := value.FirstChild(); child != nil; child = child.NextSibling() {
55
+ itemContent := blockContent(child, source)
56
+ if len(itemContent) == 0 {
57
+ itemContent = []any{paragraph(nil)}
58
+ }
59
+ items = append(items, map[string]any{"type": "listItem", "content": itemContent})
60
+ }
61
+
62
+ kind := "bulletList"
63
+ result := map[string]any{"type": kind, "content": items}
64
+ if value.IsOrdered() {
65
+ result["type"] = "orderedList"
66
+ if value.Start != 1 {
67
+ result["attrs"] = map[string]any{"order": value.Start}
68
+ }
83
69
  }
84
- return strings.HasSuffix(line, ":") || (line == strings.ToUpper(line) && strings.IndexFunc(line, func(r rune) bool { return r >= 'A' && r <= 'Z' }) >= 0)
70
+ return result
85
71
  }
86
72
 
87
- func paragraph(text string) map[string]any {
88
- if text == "" {
89
- return map[string]any{"type": "paragraph"}
73
+ func codeBlock(value, language []byte) map[string]any {
74
+ result := map[string]any{
75
+ "type": "codeBlock",
76
+ "content": []any{textNode(strings.TrimSuffix(string(value), "\n"), nil)},
90
77
  }
91
- return map[string]any{"type": "paragraph", "content": inlineContent(text)}
78
+ if len(language) != 0 {
79
+ result["attrs"] = map[string]any{"language": string(language)}
80
+ }
81
+ return result
92
82
  }
93
83
 
94
- func inlineContent(text string) []any {
95
- if strings.HasPrefix(text, "<!--") {
96
- return []any{textNode(text)}
84
+ func paragraph(content []any) map[string]any {
85
+ result := map[string]any{"type": "paragraph"}
86
+ if len(content) != 0 {
87
+ result["content"] = content
97
88
  }
98
- if i := strings.Index(text, ":"); i > 0 && i < 40 {
99
- label := textNode(text[:i+1])
100
- label["marks"] = []any{map[string]any{"type": "strong"}}
101
- return []any{label, textNode(text[i+1:])}
89
+ return result
90
+ }
91
+
92
+ func inlineContent(parent ast.Node, source []byte, marks []any) []any {
93
+ content := make([]any, 0, parent.ChildCount())
94
+ for node := parent.FirstChild(); node != nil; node = node.NextSibling() {
95
+ switch node := node.(type) {
96
+ case *ast.Text:
97
+ if value := string(node.Text(source)); value != "" {
98
+ content = append(content, textNode(value, marks))
99
+ }
100
+ if node.HardLineBreak() {
101
+ content = append(content, map[string]any{"type": "hardBreak"})
102
+ } else if node.SoftLineBreak() {
103
+ content = append(content, textNode(" ", marks))
104
+ }
105
+ case *ast.String:
106
+ if value := string(node.Value); value != "" {
107
+ content = append(content, textNode(value, marks))
108
+ }
109
+ case *ast.Emphasis:
110
+ kind := "em"
111
+ if node.Level == 2 {
112
+ kind = "strong"
113
+ }
114
+ content = append(content, inlineContent(node, source, withMark(marks, map[string]any{"type": kind}))...)
115
+ case *ast.Link:
116
+ mark := map[string]any{"type": "link", "attrs": map[string]any{"href": string(node.Destination)}}
117
+ content = append(content, inlineContent(node, source, withMark(marks, mark))...)
118
+ case *ast.AutoLink:
119
+ content = append(content, textNode(string(node.Label(source)), withMark(marks, map[string]any{
120
+ "type": "link", "attrs": map[string]any{"href": string(node.URL(source))},
121
+ })))
122
+ case *ast.CodeSpan:
123
+ content = append(content, inlineContent(node, source, withMark(marks, map[string]any{"type": "code"}))...)
124
+ case *ast.RawHTML:
125
+ content = append(content, textNode(string(node.Text(source)), marks))
126
+ default:
127
+ content = append(content, inlineContent(node, source, marks)...)
128
+ }
102
129
  }
103
- return []any{textNode(text)}
130
+ return content
104
131
  }
105
132
 
106
- func textNode(text string) map[string]any {
107
- return map[string]any{"type": "text", "text": text}
133
+ func withMark(marks []any, mark map[string]any) []any {
134
+ result := make([]any, len(marks), len(marks)+1)
135
+ copy(result, marks)
136
+ return append(result, mark)
137
+ }
138
+
139
+ func textNode(value string, marks []any) map[string]any {
140
+ result := map[string]any{"type": "text", "text": value}
141
+ if len(marks) != 0 {
142
+ result["marks"] = marks
143
+ }
144
+ return result
108
145
  }
109
146
 
110
147
  func ADFText(raw json.RawMessage) string {
@@ -0,0 +1,60 @@
1
+ package rest
2
+
3
+ import (
4
+ "encoding/json"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestADFConvertsExplicitMarkdown(t *testing.T) {
11
+ doc := ADF("# Heading\n\nWords with **strong**, *emphasis*, [a link](https://example.com), and `code`.\n\n1. first\n2. second\n\n```go\nfmt.Println(\"ok\")\n```")
12
+ content := doc["content"].([]any)
13
+
14
+ if got := content[0].(map[string]any); got["type"] != "heading" || !reflect.DeepEqual(got["attrs"], map[string]any{"level": 1}) {
15
+ t.Fatalf("heading = %#v", got)
16
+ }
17
+
18
+ raw, err := json.Marshal(doc)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ for _, want := range []string{
23
+ `"type":"strong"`,
24
+ `"type":"em"`,
25
+ `"attrs":{"href":"https://example.com"},"type":"link"`,
26
+ `"type":"code"`,
27
+ `"type":"orderedList"`,
28
+ `"attrs":{"language":"go"}`,
29
+ `"type":"codeBlock"`,
30
+ } {
31
+ if !strings.Contains(string(raw), want) {
32
+ t.Errorf("ADF missing %s: %s", want, raw)
33
+ }
34
+ }
35
+ }
36
+
37
+ func TestADFDoesNotInferFormatting(t *testing.T) {
38
+ doc := ADF("SUMMARY\n\nNode: implement\n\nHTTPS://EXAMPLE.COM")
39
+ raw, err := json.Marshal(doc)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if strings.Contains(string(raw), `"type":"heading"`) || strings.Contains(string(raw), `"type":"strong"`) {
44
+ t.Fatalf("plain text was inferred as formatting: %s", raw)
45
+ }
46
+ if got := len(doc["content"].([]any)); got != 3 {
47
+ t.Fatalf("paragraph count = %d, want 3", got)
48
+ }
49
+ }
50
+
51
+ func TestADFMarkerRoundTrip(t *testing.T) {
52
+ const marker = "<!-- run-1:summary -->"
53
+ raw, err := json.Marshal(ADF("Done.\n\n" + marker))
54
+ if err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ if got := ADFText(raw); !strings.Contains(got, marker) {
58
+ t.Fatalf("ADFText() = %q, want marker %q", got, marker)
59
+ }
60
+ }
@@ -355,7 +355,7 @@ func TestErrorRedactsToken(t *testing.T) {
355
355
  }
356
356
 
357
357
  func TestADFFormatsHeadingsListsAndRoundTripsMarker(t *testing.T) {
358
- doc := ADF("SUMMARY\n\nNode: implement\n\n- first\n- second\n\n```\ngo test ./...\n```\n\n<!-- visit:summary -->")
358
+ doc := ADF("### Summary\n\n**Node:** implement\n\n- first\n- second\n\n```\ngo test ./...\n```\n\n<!-- visit:summary -->")
359
359
  raw, err := json.Marshal(doc)
360
360
  if err != nil {
361
361
  t.Fatal(err)
@@ -0,0 +1,118 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ )
14
+
15
+ func TestTaskTextTemplatesExposeValues(t *testing.T) {
16
+ fake := &fakeJira{}
17
+ sys, err := newSystem(context.Background(), &fakeClient{fake: fake}, task.RepoSpec{
18
+ Name: "payments",
19
+ RootConfig: config.RawValues{"templates": map[string]any{
20
+ "mailboxDescription": "mailbox {{runID}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{successRoutes}}|{{failureRoutes}}|{{mailbox}}",
21
+ "summaryComment": "summary {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{summaryReport}}",
22
+ "feedbackComment": "feedback {{sourceNode}}|{{targetNode}}|{{mailbox}}\n{{feedbackReport}}",
23
+ }},
24
+ RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
25
+ })
26
+ if err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ data := task.TextData{
30
+ RunID: "run-1", Ticket: "PAY-1", Workflow: "flow", Repo: "payments",
31
+ Node: "review", NodeType: "hitl", Agent: "reviewer", NodeDescription: "Review changes.",
32
+ NextSteps: "implement; end", SuccessRoutes: "end", FailureRoutes: "implement",
33
+ Mailbox: "PAY-3", SourceNode: "review", TargetNode: "implement",
34
+ SummaryReport: "COMPLETED:\nreviewed", FeedbackReport: "REQUIRED ACTIONS:\nfix it",
35
+ }
36
+ description, err := sys.RenderText(task.TextMailboxDescription, data)
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ for _, want := range []string{"run-1", "PAY-1", "flow", "payments", "review", "hitl", "reviewer", "Review changes.", "implement; end", "PAY-3"} {
41
+ if !strings.Contains(description, want) {
42
+ t.Fatalf("description missing %q: %q", want, description)
43
+ }
44
+ }
45
+ summary, err := sys.RenderText(task.TextSummaryComment, data)
46
+ if err != nil || !strings.Contains(summary, data.SummaryReport) {
47
+ t.Fatalf("summary = %q, %v", summary, err)
48
+ }
49
+ feedback, err := sys.RenderText(task.TextFeedbackComment, data)
50
+ if err != nil || !strings.Contains(feedback, "feedback review|implement|PAY-3") || !strings.Contains(feedback, data.FeedbackReport) {
51
+ t.Fatalf("feedback = %q, %v", feedback, err)
52
+ }
53
+ }
54
+
55
+ func TestCommentTemplatesRequireReportValues(t *testing.T) {
56
+ for _, tc := range []struct {
57
+ name string
58
+ override map[string]any
59
+ wantError string
60
+ }{
61
+ {name: "summary", override: map[string]any{"summaryComment": "missing"}, wantError: "summaryComment must contain {{summaryReport}}"},
62
+ {name: "feedback", override: map[string]any{"feedbackComment": "missing"}, wantError: "feedbackComment must contain {{feedbackReport}}"},
63
+ {name: "unknown", override: map[string]any{"mailboxDescription": "{{mailboxInstructions}}"}, wantError: "unknown template variable {{mailboxInstructions}}"},
64
+ } {
65
+ t.Run(tc.name, func(t *testing.T) {
66
+ raw := config.Merge(DefaultConfig(), config.RawValues{"templates": tc.override})
67
+ var cfg Config
68
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ err := validateTemplates(cfg.Templates)
72
+ if err == nil || !strings.Contains(err.Error(), tc.wantError) {
73
+ t.Fatalf("validateTemplates error = %v, want %q", err, tc.wantError)
74
+ }
75
+ })
76
+ }
77
+ }
78
+
79
+ func TestTaskTemplatesRenderSharedReportContractValues(t *testing.T) {
80
+ b, err := os.ReadFile("../../../testdata/report-contract.json")
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ var fixtures map[string]struct {
85
+ Envelope run.ReportRequest `json:"envelope"`
86
+ SummaryReport string `json:"summaryReport"`
87
+ FeedbackReport string `json:"feedbackReport"`
88
+ }
89
+ if err := json.Unmarshal(b, &fixtures); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ fixture := fixtures["work"]
93
+ sys, err := newSystem(context.Background(), &fakeClient{fake: &fakeJira{}}, task.RepoSpec{
94
+ Name: "payments", RepoConfig: config.RawValues{"project": "PAY", "component": "api"},
95
+ })
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ data := task.TextData{
100
+ Node: "coding", SourceNode: "coding", TargetNode: "coding", Mailbox: "PAY-101:coding",
101
+ SummaryReport: fixture.SummaryReport, FeedbackReport: fixture.FeedbackReport,
102
+ }
103
+ summary, err := sys.RenderText(task.TextSummaryComment, data)
104
+ if err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ if summary != "Summary for coding\n\n"+fixture.SummaryReport {
108
+ t.Fatalf("summary template did not render shared summaryReport:\n%s", summary)
109
+ }
110
+ feedback, err := sys.RenderText(task.TextFeedbackComment, data)
111
+ if err != nil {
112
+ t.Fatal(err)
113
+ }
114
+ wantFeedback := "Feedback from coding to coding mailbox PAY-101:coding\n\n" + fixture.FeedbackReport
115
+ if feedback != wantFeedback {
116
+ t.Fatalf("feedback template did not render shared feedbackReport:\n%s", feedback)
117
+ }
118
+ }
@@ -6,6 +6,7 @@ import (
6
6
  "testing"
7
7
 
8
8
  "github.com/rajpopat27/relay-flow/internal/config"
9
+ "github.com/rajpopat27/relay-flow/internal/retry"
9
10
  "github.com/rajpopat27/relay-flow/internal/task"
10
11
  )
11
12
 
@@ -27,6 +28,7 @@ type fakeJira struct {
27
28
  taskTransitions []string
28
29
  assignments []string
29
30
  assignErr error
31
+ transitionErr error
30
32
  events []string
31
33
  // searchJSON is the raw Jira search response Poll serves.
32
34
  searchJSON []byte
@@ -128,8 +130,10 @@ func TestEndDefaultParentDone(t *testing.T) {
128
130
 
129
131
  // end: omitted transitionTo. The end effective config carries no
130
132
  // transition; the adapter must still default the parent to Done. Run
131
- // orchestration applies the end node config to the parent target.
132
- if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endConfig(config.RawValues{})); err != nil {
133
+ // orchestration merges EndDefaults under the end node config before
134
+ // applying it to the parent target.
135
+ endCfg := config.Merge(sys.(task.LifecycleDefaults).EndDefaults(), config.RawValues{})
136
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endCfg); err != nil {
133
137
  t.Fatalf("ApplyTaskConfig end failed: %v", err)
134
138
  }
135
139
  if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
@@ -137,6 +141,47 @@ func TestEndDefaultParentDone(t *testing.T) {
137
141
  }
138
142
  }
139
143
 
144
+ func TestPrepareRestartReopensMailboxesWithoutChangingParent(t *testing.T) {
145
+ fake := &fakeJira{}
146
+ sys := newSystemWithFake(t, fake)
147
+ preparer, ok := sys.(task.RestartPreparer)
148
+ if !ok {
149
+ t.Fatal("Jira system does not implement RestartPreparer")
150
+ }
151
+ parent := task.TicketRef{ID: "1", Key: "PAY-101"}
152
+ mailboxes := []task.Mailbox{
153
+ {ID: "2", Key: "PAY-102", Node: "coding"},
154
+ {ID: "3", Key: "PAY-103", Node: "review"},
155
+ }
156
+ if err := preparer.PrepareRestart(context.Background(), parent, mailboxes); err != nil {
157
+ t.Fatalf("PrepareRestart failed: %v", err)
158
+ }
159
+ if len(fake.taskTransitions) != 2 || fake.taskTransitions[0] != "To Do" || fake.taskTransitions[1] != "To Do" {
160
+ t.Fatalf("mailbox transitions = %v, want two To Do transitions", fake.taskTransitions)
161
+ }
162
+ if len(fake.parentTransitions) != 0 {
163
+ t.Fatalf("parent transitions = %v, want none (start owns parent status)", fake.parentTransitions)
164
+ }
165
+ }
166
+
167
+ func TestPrepareRestartPreservesHumanJiraStateOnConflict(t *testing.T) {
168
+ fake := &fakeJira{transitionErr: errors.New(`transition to "To Do" is not available for PAY-102`)}
169
+ sys := newSystemWithFake(t, fake)
170
+ preparer := sys.(task.RestartPreparer)
171
+ err := preparer.PrepareRestart(context.Background(), task.TicketRef{Key: "PAY-101"}, []task.Mailbox{
172
+ {ID: "2", Key: "PAY-102", Node: "coding"},
173
+ })
174
+ if err == nil {
175
+ t.Fatal("incompatible Jira mailbox status was accepted")
176
+ }
177
+ if got := retry.Classify(err).Kind; got != retry.Conflict {
178
+ t.Fatalf("failure kind = %q, want conflict: %v", got, err)
179
+ }
180
+ if len(fake.parentTransitions) != 0 {
181
+ t.Fatalf("parent transitions = %v, want none", fake.parentTransitions)
182
+ }
183
+ }
184
+
140
185
  func TestExplicitTransitionsWin(t *testing.T) {
141
186
  fake := &fakeJira{}
142
187
  sys := newSystemWithFake(t, fake)
@@ -37,6 +37,37 @@ type MailboxSpec struct {
37
37
  Title string `json:"title"`
38
38
  Description string `json:"description"`
39
39
  TaskConfig config.RawValues `json:"taskConfig,omitempty"`
40
+ TextData TextData `json:"textData"`
41
+ }
42
+
43
+ type TextKind string
44
+
45
+ const (
46
+ TextMailboxDescription TextKind = "mailboxDescription"
47
+ TextSummaryComment TextKind = "summaryComment"
48
+ TextFeedbackComment TextKind = "feedbackComment"
49
+ )
50
+
51
+ // TextData contains existing run, mailbox, and node values supplied to a
52
+ // task-system text template. The task system owns rendering; harness prompt
53
+ // templates are a separate contract.
54
+ type TextData struct {
55
+ RunID string
56
+ Ticket string
57
+ Workflow string
58
+ Repo string
59
+ Node string
60
+ NodeType string
61
+ Agent string
62
+ NodeDescription string
63
+ NextSteps string
64
+ SuccessRoutes string
65
+ FailureRoutes string
66
+ Mailbox string
67
+ SourceNode string
68
+ TargetNode string
69
+ SummaryReport string
70
+ FeedbackReport string
40
71
  }
41
72
 
42
73
  type Target struct {
@@ -59,6 +90,7 @@ type System interface {
59
90
  Claim(ctx context.Context, ticket TicketRef, workflow string) error
60
91
 
61
92
  ValidateConfig(ctx context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error
93
+ RenderText(kind TextKind, data TextData) (string, error)
62
94
  EnsureMailboxes(ctx context.Context, parent TicketRef, workflow string, specs []MailboxSpec) (map[string]Mailbox, error)
63
95
  ApplyTaskConfig(ctx context.Context, target Target, taskConfig config.RawValues) error
64
96
  CompleteMailbox(ctx context.Context, mailbox Mailbox) error
@@ -67,6 +99,14 @@ type System interface {
67
99
  ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
68
100
  }
69
101
 
102
+ // RestartPreparer is an optional adapter capability used only by explicit
103
+ // run restarts. It reopens relay-owned mailbox state while preserving all
104
+ // comments, labels, and descriptions. A human-owned incompatible state must
105
+ // be returned as retry.ConflictError; core never names provider statuses.
106
+ type RestartPreparer interface {
107
+ PrepareRestart(ctx context.Context, parent TicketRef, mailboxes []Mailbox) error
108
+ }
109
+
70
110
  // LifecycleDefaults is an optional adapter capability: a System whose
71
111
  // taskConfig carries lifecycle-dependent defaults (e.g. Jira's deterministic
72
112
  // transitionTo defaults) exposes them here. The lifecycle-aware caller
@@ -1,9 +1,12 @@
1
1
  package workflow_test
2
2
 
3
3
  import (
4
+ "encoding/json"
5
+ "os"
4
6
  "strings"
5
7
  "testing"
6
8
 
9
+ "github.com/rajpopat27/relay-flow/internal/run"
7
10
  "github.com/rajpopat27/relay-flow/internal/workflow"
8
11
  )
9
12
 
@@ -39,6 +42,48 @@ func noneFeedback() workflow.Feedback {
39
42
  }
40
43
  }
41
44
 
45
+ type reportContractFixture struct {
46
+ AssistantText string `json:"assistantText"`
47
+ Envelope run.ReportRequest `json:"envelope"`
48
+ SummaryReport string `json:"summaryReport"`
49
+ FeedbackReport string `json:"feedbackReport"`
50
+ }
51
+
52
+ func reportContractFixtures(t *testing.T) map[string]reportContractFixture {
53
+ t.Helper()
54
+ b, err := os.ReadFile("../../testdata/report-contract.json")
55
+ if err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ var fixtures map[string]reportContractFixture
59
+ if err := json.Unmarshal(b, &fixtures); err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ return fixtures
63
+ }
64
+
65
+ func TestSharedReportContractFixtureMatchesGoValidation(t *testing.T) {
66
+ wf := parse(t, "basicFlow", minimalValid)
67
+ fixtures := reportContractFixtures(t)
68
+ for _, name := range []string{"work", "end"} {
69
+ t.Run(name, func(t *testing.T) {
70
+ fixture := fixtures[name]
71
+ if fixture.Envelope.Report.Summary.Completed == "" || fixture.Envelope.Report.Feedback.ReasonForNextStep == "" {
72
+ t.Fatal("shared report fixture must contain both summary and feedback")
73
+ }
74
+ if err := wf.ValidateReport(fixture.Envelope.Node, fixture.Envelope.Report); err != nil {
75
+ t.Fatalf("shared report fixture rejected: %v", err)
76
+ }
77
+ })
78
+ }
79
+
80
+ invalidEnd := fixtures["end"].Envelope.Report
81
+ invalidEnd.Feedback.RequiredActions = "do more work"
82
+ if err := wf.ValidateReport("coding", invalidEnd); err == nil {
83
+ t.Fatal("end fixture with feedback content accepted")
84
+ }
85
+ }
86
+
42
87
  func TestValidateReportAcceptsCompleteSuccess(t *testing.T) {
43
88
  wf := parse(t, "basicFlow", minimalValid)
44
89
  r := workflow.Report{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.2.1-alpha",
3
+ "version": "0.2.3-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",