relay-flow 0.0.1 → 0.2.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,120 @@
1
+ [
2
+ {
3
+ "changelog": null,
4
+ "editmeta": null,
5
+ "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
6
+ "fields": {
7
+ "assignee": {
8
+ "accountId": "712020:dad5df85-a7bd-4337-b858-0faf9012a009",
9
+ "accountType": "atlassian",
10
+ "active": true,
11
+ "avatarUrls": {
12
+ "16x16": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png",
13
+ "24x24": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png",
14
+ "32x32": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png",
15
+ "48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
16
+ },
17
+ "displayName": "Raj Popat",
18
+ "emailAddress": "raj.popat@wolterskluwer.com",
19
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
20
+ "timeZone": "America/New_York"
21
+ },
22
+ "issuetype": {
23
+ "avatarId": 10318,
24
+ "description": "A small, distinct piece of work.",
25
+ "hierarchyLevel": 0,
26
+ "iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10318?size=medium",
27
+ "id": "10017",
28
+ "name": "Task",
29
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issuetype/10017",
30
+ "subtask": false
31
+ },
32
+ "labels": [],
33
+ "status": {
34
+ "description": "",
35
+ "iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/",
36
+ "id": "10007",
37
+ "name": "To Do",
38
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/status/10007",
39
+ "statusCategory": {
40
+ "colorName": "blue-gray",
41
+ "id": 2,
42
+ "key": "new",
43
+ "name": "To Do",
44
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/statuscategory/2"
45
+ }
46
+ },
47
+ "summary": "e2e: hello world program in python"
48
+ },
49
+ "fieldsToInclude": null,
50
+ "id": "157320",
51
+ "key": "GHCOS-41242",
52
+ "names": null,
53
+ "operations": null,
54
+ "properties": null,
55
+ "renderedFields": null,
56
+ "schema": null,
57
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issue/157320",
58
+ "transitions": null,
59
+ "versionedRepresentations": null
60
+ },
61
+ {
62
+ "changelog": null,
63
+ "editmeta": null,
64
+ "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations",
65
+ "fields": {
66
+ "assignee": {
67
+ "accountId": "712020:dad5df85-a7bd-4337-b858-0faf9012a009",
68
+ "accountType": "atlassian",
69
+ "active": true,
70
+ "avatarUrls": {
71
+ "16x16": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png",
72
+ "24x24": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png",
73
+ "32x32": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png",
74
+ "48x48": "https://secure.gravatar.com/avatar/09110b9326675e772c94aa61fc89195b?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FRP-6.png"
75
+ },
76
+ "displayName": "Raj Popat",
77
+ "emailAddress": "raj.popat@wolterskluwer.com",
78
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/user?accountId=712020%3Adad5df85-a7bd-4337-b858-0faf9012a009",
79
+ "timeZone": "America/New_York"
80
+ },
81
+ "issuetype": {
82
+ "avatarId": 10303,
83
+ "description": "A problem or error.",
84
+ "hierarchyLevel": 0,
85
+ "iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/2/universal_avatar/view/type/issuetype/avatar/10303?size=medium",
86
+ "id": "10019",
87
+ "name": "Bug",
88
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issuetype/10019",
89
+ "subtask": false
90
+ },
91
+ "labels": [],
92
+ "status": {
93
+ "description": "",
94
+ "iconUrl": "https://jira-prod-us-26-3.prod.atl-paas.net/",
95
+ "id": "10007",
96
+ "name": "To Do",
97
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/status/10007",
98
+ "statusCategory": {
99
+ "colorName": "blue-gray",
100
+ "id": 2,
101
+ "key": "new",
102
+ "name": "To Do",
103
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/statuscategory/2"
104
+ }
105
+ },
106
+ "summary": "OpsDB Sync Validation Bug"
107
+ },
108
+ "fieldsToInclude": null,
109
+ "id": "155110",
110
+ "key": "GHCOS-41175",
111
+ "names": null,
112
+ "operations": null,
113
+ "properties": null,
114
+ "renderedFields": null,
115
+ "schema": null,
116
+ "self": "https://jira-prod-us-26-3.prod.atl-paas.net/rest/api/3/issue/155110",
117
+ "transitions": null,
118
+ "versionedRepresentations": null
119
+ }
120
+ ]
@@ -0,0 +1,156 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/config"
9
+ "github.com/rajpopat27/relay-flow/internal/task"
10
+ )
11
+
12
+ // 3.5: Jira transition defaults per specs/workflow-definition "Jira
13
+ // transition defaults are deterministic": omitted transitions default to
14
+ // parent In Progress at start, mailbox In Progress at agent/HITL work
15
+ // nodes, and parent Done at end; an omitted work-node parent transition
16
+ // leaves the parent unchanged.
17
+ //
18
+ // This test is package-local (package jira) so it can drive the adapter's
19
+ // ApplyTaskConfig against a test-local fake ACLI without inventing an
20
+ // exported production constructor seam. The adapter resolves defaults
21
+ // internally; the fake records the transitions the adapter issues.
22
+
23
+ // fakeACLI is a test-local fakeable ACLI boundary recording transitions and
24
+ // serving a fixed search batch for Poll.
25
+ type fakeACLI struct {
26
+ parentTransitions []string
27
+ taskTransitions []string
28
+ assignments []string
29
+ assignErr error
30
+ events []string
31
+ // searchJSON is the raw Jira search response Poll serves.
32
+ searchJSON []byte
33
+ }
34
+
35
+ func (f *fakeACLI) transition(key, status string) {
36
+ if key == "PAY-101" {
37
+ f.parentTransitions = append(f.parentTransitions, status)
38
+ } else {
39
+ f.taskTransitions = append(f.taskTransitions, status)
40
+ }
41
+ }
42
+
43
+ // newSystemWithFake builds the adapter around the test-local fake ACLI. The
44
+ // adapter owns its ACLI wiring; the test injects only the CLI seam.
45
+ func newSystemWithFake(t *testing.T, fake *fakeACLI) task.System {
46
+ t.Helper()
47
+ sys, err := newSystemForTest(fake)
48
+ if err != nil {
49
+ t.Fatalf("adapter construction failed: %v", err)
50
+ }
51
+ return sys
52
+ }
53
+
54
+ func TestStartDefaultParentInProgress(t *testing.T) {
55
+ fake := &fakeACLI{}
56
+ sys := newSystemWithFake(t, fake)
57
+ parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
58
+
59
+ // start: omitted transitionTo, parent-only target.
60
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, config.RawValues{}); err != nil {
61
+ t.Fatalf("ApplyTaskConfig start failed: %v", err)
62
+ }
63
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Progress" {
64
+ t.Fatalf("start parent transitions = %v, want [In Progress]", fake.parentTransitions)
65
+ }
66
+ }
67
+
68
+ func TestWorkNodeDefaultMailboxInProgressParentUnchanged(t *testing.T) {
69
+ fake := &fakeACLI{}
70
+ sys := newSystemWithFake(t, fake)
71
+ parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
72
+ mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
73
+
74
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{}); err != nil {
75
+ t.Fatalf("ApplyTaskConfig failed: %v", err)
76
+ }
77
+ if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Progress" {
78
+ t.Fatalf("mailbox transitions = %v, want [In Progress]", fake.taskTransitions)
79
+ }
80
+ if len(fake.parentTransitions) != 0 {
81
+ t.Fatalf("parent transitions = %v, want none (parent unchanged when omitted)", fake.parentTransitions)
82
+ }
83
+ if len(fake.assignments) != 0 {
84
+ t.Fatalf("mailbox assignments = %v, want none when assignee omitted", fake.assignments)
85
+ }
86
+ }
87
+
88
+ func TestWorkNodeAssignsMailboxBeforeTransition(t *testing.T) {
89
+ fake := &fakeACLI{}
90
+ sys := newSystemWithFake(t, fake)
91
+ parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
92
+ mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
93
+
94
+ err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
95
+ "assignee": "reviewer@example.com",
96
+ })
97
+ if err != nil {
98
+ t.Fatalf("ApplyTaskConfig failed: %v", err)
99
+ }
100
+ if len(fake.assignments) != 1 || fake.assignments[0] != "PAY-102:reviewer@example.com" {
101
+ t.Fatalf("mailbox assignments = %v, want [PAY-102:reviewer@example.com]", fake.assignments)
102
+ }
103
+ if len(fake.events) != 2 || fake.events[0] != "assign" || fake.events[1] != "transition" {
104
+ t.Fatalf("mailbox events = %v, want [assign transition]", fake.events)
105
+ }
106
+ }
107
+
108
+ func TestWorkNodeAssignmentFailurePreventsTransition(t *testing.T) {
109
+ fake := &fakeACLI{assignErr: errors.New("assignment failed")}
110
+ sys := newSystemWithFake(t, fake)
111
+ parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
112
+ mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "coding"}
113
+
114
+ err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
115
+ "assignee": "reviewer@example.com",
116
+ })
117
+ if err == nil || len(fake.taskTransitions) != 0 {
118
+ t.Fatalf("ApplyTaskConfig error = %v, transitions = %v", err, fake.taskTransitions)
119
+ }
120
+ }
121
+
122
+ func TestEndDefaultParentDone(t *testing.T) {
123
+ fake := &fakeACLI{}
124
+ sys := newSystemWithFake(t, fake)
125
+ parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
126
+
127
+ // end: omitted transitionTo. The end effective config carries no
128
+ // transition; the adapter must still default the parent to Done. Run
129
+ // orchestration applies the end node config to the parent target.
130
+ if err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent}, endConfig(config.RawValues{})); err != nil {
131
+ t.Fatalf("ApplyTaskConfig end failed: %v", err)
132
+ }
133
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "Done" {
134
+ t.Fatalf("end parent transitions = %v, want [Done]", fake.parentTransitions)
135
+ }
136
+ }
137
+
138
+ func TestExplicitTransitionsWin(t *testing.T) {
139
+ fake := &fakeACLI{}
140
+ sys := newSystemWithFake(t, fake)
141
+ parent := task.TicketRef{ID: "1", Key: "PAY-101", Title: "parent"}
142
+ mb := task.Mailbox{ID: "2", Key: "PAY-102", Node: "review"}
143
+
144
+ err := sys.ApplyTaskConfig(context.Background(), task.Target{Parent: parent, Mailbox: &mb}, config.RawValues{
145
+ "transitionTo": map[string]any{"parentStatus": "In Review", "taskStatus": "In Review"},
146
+ })
147
+ if err != nil {
148
+ t.Fatalf("ApplyTaskConfig failed: %v", err)
149
+ }
150
+ if len(fake.parentTransitions) != 1 || fake.parentTransitions[0] != "In Review" {
151
+ t.Fatalf("parent transitions = %v, want [In Review]", fake.parentTransitions)
152
+ }
153
+ if len(fake.taskTransitions) != 1 || fake.taskTransitions[0] != "In Review" {
154
+ t.Fatalf("mailbox transitions = %v, want [In Review]", fake.taskTransitions)
155
+ }
156
+ }
@@ -0,0 +1,94 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/config"
10
+ "github.com/rajpopat27/relay-flow/internal/task"
11
+ )
12
+
13
+ type validationClient struct {
14
+ fakeClient
15
+ assignees []string
16
+ statuses []string
17
+ }
18
+
19
+ func (c *validationClient) ValidateAssignee(_ context.Context, assignee string) error {
20
+ c.assignees = append(c.assignees, assignee)
21
+ if assignee == "missing" {
22
+ return errors.New("invalid assignee")
23
+ }
24
+ return nil
25
+ }
26
+
27
+ func (c *validationClient) ValidateStatus(_ context.Context, project, status string) error {
28
+ c.statuses = append(c.statuses, project+":"+status)
29
+ if status == "DO Done" {
30
+ return errors.New("invalid status")
31
+ }
32
+ return nil
33
+ }
34
+
35
+ func TestNewSystemRejectsInvalidAssignee(t *testing.T) {
36
+ client := &validationClient{}
37
+ _, err := newSystem(context.Background(), client, taskSpec(config.RawValues{"assignee": "missing"}, nil))
38
+ if err == nil || !strings.Contains(err.Error(), `assignee "missing"`) {
39
+ t.Fatalf("newSystem error = %v", err)
40
+ }
41
+ if len(client.assignees) != 1 || client.assignees[0] != "missing" {
42
+ t.Fatalf("validated assignees = %v", client.assignees)
43
+ }
44
+ }
45
+
46
+ func TestValidateConfigProbesMergedTransitionStatusesWithoutMutation(t *testing.T) {
47
+ client := &validationClient{}
48
+ sys, err := newSystem(context.Background(), client, taskSpec(config.RawValues{"assignee": "valid"}, nil))
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ wf := config.RawValues{"transitionTo": map[string]any{"parentStatus": "In Review"}}
53
+ node := config.RawValues{"transitionTo": map[string]any{"taskStatus": "DO Done"}}
54
+ err = sys.ValidateConfig(context.Background(), wf, map[string]config.RawValues{"review": node})
55
+ if err == nil || !strings.Contains(err.Error(), `node "review" taskStatus "DO Done"`) {
56
+ t.Fatalf("ValidateConfig error = %v", err)
57
+ }
58
+ if got := wf["transitionTo"].(map[string]any); len(got) != 1 || got["parentStatus"] != "In Review" {
59
+ t.Fatalf("workflow config mutated: %v", wf)
60
+ }
61
+ if got := node["transitionTo"].(map[string]any); len(got) != 1 || got["taskStatus"] != "DO Done" {
62
+ t.Fatalf("node config mutated: %v", node)
63
+ }
64
+ }
65
+
66
+ func TestNewSystemRejectsInvalidRepoTransitionStatus(t *testing.T) {
67
+ client := &validationClient{}
68
+ _, err := newSystem(context.Background(), client, taskSpec(nil, config.RawValues{
69
+ "project": "PAY", "component": "api",
70
+ "transitionTo": map[string]any{"parentStatus": "DO Done"},
71
+ }))
72
+ if err == nil || !strings.Contains(err.Error(), `repo config parentStatus "DO Done"`) {
73
+ t.Fatalf("newSystem error = %v", err)
74
+ }
75
+ }
76
+
77
+ func TestValidateConfigProbesWorkflowAssignee(t *testing.T) {
78
+ client := &validationClient{}
79
+ sys, err := newSystem(context.Background(), client, taskSpec(nil, nil))
80
+ if err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ err = sys.ValidateConfig(context.Background(), config.RawValues{"assignee": "missing"}, nil)
84
+ if err == nil || !strings.Contains(err.Error(), `workflow assignee "missing"`) {
85
+ t.Fatalf("ValidateConfig error = %v", err)
86
+ }
87
+ }
88
+
89
+ func taskSpec(root, repo config.RawValues) task.RepoSpec {
90
+ if repo == nil {
91
+ repo = config.RawValues{"project": "PAY", "component": "api"}
92
+ }
93
+ return task.RepoSpec{Name: "payments", RootConfig: root, RepoConfig: repo}
94
+ }
@@ -0,0 +1,84 @@
1
+ // Package task defines task-system values and the System contract. Run IDs,
2
+ // node visits, reports, and orchestration inputs belong to run, not here.
3
+ package task
4
+
5
+ import (
6
+ "context"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/config"
9
+ )
10
+
11
+ type Ticket struct {
12
+ ID string
13
+ Key string
14
+ Title string
15
+ WorkflowClaims []string
16
+ Fields map[string]any
17
+ }
18
+
19
+ type TicketRef struct {
20
+ ID string `json:"id"`
21
+ Key string `json:"key"`
22
+ Title string `json:"title"`
23
+ }
24
+
25
+ func (t Ticket) Ref() TicketRef {
26
+ return TicketRef{ID: t.ID, Key: t.Key, Title: t.Title}
27
+ }
28
+
29
+ type Mailbox struct {
30
+ ID string `json:"id"`
31
+ Key string `json:"key"`
32
+ Node string `json:"node"`
33
+ }
34
+
35
+ type MailboxSpec struct {
36
+ Node string `json:"node"`
37
+ Title string `json:"title"`
38
+ Description string `json:"description"`
39
+ TaskConfig config.RawValues `json:"taskConfig,omitempty"`
40
+ }
41
+
42
+ type Target struct {
43
+ Parent TicketRef
44
+ Mailbox *Mailbox
45
+ }
46
+
47
+ // System is the task-system contract. One instance is created per
48
+ // registered repo and is safe for concurrent use.
49
+ //
50
+ // Poll returns active parent tickets only; mailbox subtasks are never
51
+ // routed as workflow runs. EnsureMailboxes is the sole mailbox discovery
52
+ // operation: it finds existing child mailboxes, creates only missing ones,
53
+ // and returns the complete node-to-mailbox map. Each method is idempotent
54
+ // where the task system permits; adapters own reconciliation when one
55
+ // method requires multiple remote calls.
56
+ type System interface {
57
+ Poll(ctx context.Context) ([]Ticket, error)
58
+ CompileFilter(workflowTaskConfig config.RawValues) (func(Ticket) bool, error)
59
+ Claim(ctx context.Context, ticket TicketRef, workflow string) error
60
+
61
+ ValidateConfig(ctx context.Context, workflowTaskConfig config.RawValues, nodeTaskConfigs map[string]config.RawValues) error
62
+ EnsureMailboxes(ctx context.Context, parent TicketRef, workflow string, specs []MailboxSpec) (map[string]Mailbox, error)
63
+ ApplyTaskConfig(ctx context.Context, target Target, taskConfig config.RawValues) error
64
+ CompleteMailbox(ctx context.Context, mailbox Mailbox) error
65
+ HasComment(ctx context.Context, target Target, marker string) (bool, error)
66
+ Comment(ctx context.Context, target Target, body, marker string) error
67
+ ResetForRecovery(ctx context.Context, parent TicketRef, mailboxes []Mailbox, taskConfig config.RawValues) error
68
+ }
69
+
70
+ // LifecycleDefaults is an optional adapter capability: a System whose
71
+ // taskConfig carries lifecycle-dependent defaults (e.g. Jira's deterministic
72
+ // transitionTo defaults) exposes them here. The lifecycle-aware caller
73
+ // (run orchestration) merges the returned raw values into the effective node
74
+ // config before ApplyTaskConfig based on whether the node is start, a work
75
+ // node, or end. Core never learns adapter vocabulary; the adapter never
76
+ // learns lifecycle steps. The System interface is unchanged.
77
+ type LifecycleDefaults interface {
78
+ // StartDefaults are merged under the start node's taskConfig.
79
+ StartDefaults() config.RawValues
80
+ // WorkDefaults are merged under every work node's taskConfig.
81
+ WorkDefaults() config.RawValues
82
+ // EndDefaults are merged under the end node's taskConfig.
83
+ EndDefaults() config.RawValues
84
+ }
@@ -0,0 +1,85 @@
1
+ package workflow
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ )
7
+
8
+ // Structured report contract. All strings are required; the literal "None"
9
+ // represents an intentionally empty section.
10
+ const None = "None"
11
+
12
+ type Summary struct {
13
+ Completed string `json:"completed"`
14
+ Commits string `json:"commits"`
15
+ NotCompleted string `json:"notCompleted"`
16
+ IssuesDiscovered string `json:"issuesDiscovered"`
17
+ Verification string `json:"verification"`
18
+ Notes string `json:"notes"`
19
+ }
20
+
21
+ type Feedback struct {
22
+ ReasonForNextStep string `json:"reasonForNextStep"`
23
+ RequiredActions string `json:"requiredActions"`
24
+ RelevantContext string `json:"relevantContext"`
25
+ ExpectedResult string `json:"expectedResult"`
26
+ }
27
+
28
+ type Report struct {
29
+ Status Outcome `json:"status"`
30
+ NextStep string `json:"nextStep"`
31
+ Summary Summary `json:"summary"`
32
+ Feedback Feedback `json:"feedback"`
33
+ }
34
+
35
+ // ValidateReport verifies every section, the outcome, and that NextStep
36
+ // names exactly one configured target for that outcome. When NextStep is
37
+ // end, every feedback field must be None. It is pure and is called both at
38
+ // the server boundary and inside durable execution.
39
+ func (w *Workflow) ValidateReport(node string, report Report) error {
40
+ if _, ok := w.Nodes[node]; !ok {
41
+ return fmt.Errorf("workflow %q has no node %q", w.Name, node)
42
+ }
43
+ if report.Status != OutcomeSuccess && report.Status != OutcomeFailure {
44
+ return fmt.Errorf("report status %q must be %q or %q", report.Status, OutcomeSuccess, OutcomeFailure)
45
+ }
46
+ for _, field := range []struct{ name, value string }{
47
+ {"summary.completed", report.Summary.Completed},
48
+ {"summary.commits", report.Summary.Commits},
49
+ {"summary.notCompleted", report.Summary.NotCompleted},
50
+ {"summary.issuesDiscovered", report.Summary.IssuesDiscovered},
51
+ {"summary.verification", report.Summary.Verification},
52
+ {"summary.notes", report.Summary.Notes},
53
+ {"feedback.reasonForNextStep", report.Feedback.ReasonForNextStep},
54
+ {"feedback.requiredActions", report.Feedback.RequiredActions},
55
+ {"feedback.relevantContext", report.Feedback.RelevantContext},
56
+ {"feedback.expectedResult", report.Feedback.ExpectedResult},
57
+ {"nextStep", report.NextStep},
58
+ } {
59
+ if field.value == "" {
60
+ return fmt.Errorf("report section %s is required (use %q for an intentionally empty section)", field.name, None)
61
+ }
62
+ }
63
+ routes, err := w.Routes(node, report.Status)
64
+ if err != nil {
65
+ return err
66
+ }
67
+ legal := false
68
+ for _, r := range routes {
69
+ if r.Target == report.NextStep {
70
+ legal = true
71
+ break
72
+ }
73
+ }
74
+ if !legal {
75
+ return fmt.Errorf("report nextStep %q is not a configured %s target for node %q; valid targets: %s",
76
+ report.NextStep, report.Status, node, strings.Join(sortedTargets(routes), ", "))
77
+ }
78
+ if report.NextStep == EndNode {
79
+ f := report.Feedback
80
+ if f.ReasonForNextStep != None || f.RequiredActions != None || f.RelevantContext != None || f.ExpectedResult != None {
81
+ return fmt.Errorf("report selects %q: every feedback field must be %q because end has no mailbox", EndNode, None)
82
+ }
83
+ }
84
+ return nil
85
+ }