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,234 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/config"
10
+ "github.com/rajpopat27/relay-flow/internal/task"
11
+ )
12
+
13
+ // 3.13: filter tests per specs/repo-workflow-routing "Workflow filters are
14
+ // structured and locally evaluable": the adapter compiles workflow
15
+ // taskConfig.filters into matchers over normalized ticket fields; matching
16
+ // is in-memory against one repo poll batch (no per-workflow re-query).
17
+ // Package-local so it can use the adapter's test seams without exporting
18
+ // production API.
19
+
20
+ // pollCountingSystem wraps the adapter's Poll to prove matchers never
21
+ // trigger another fetch.
22
+ type pollCountingSystem struct {
23
+ task.System
24
+ polls int
25
+ }
26
+
27
+ func (p *pollCountingSystem) Poll(ctx context.Context) ([]task.Ticket, error) {
28
+ p.polls++
29
+ return p.System.Poll(ctx)
30
+ }
31
+
32
+ func TestCompileFilterMatchesNormalizedFields(t *testing.T) {
33
+ sys := newSystemWithFake(t, &fakeACLI{})
34
+
35
+ match, err := sys.CompileFilter(config.RawValues{
36
+ "filters": map[string]any{
37
+ "parentStatuses": []any{"To Do"},
38
+ "issueTypes": []any{"Task"},
39
+ "labels": []any{"coding"},
40
+ },
41
+ })
42
+ if err != nil {
43
+ t.Fatalf("CompileFilter failed: %v", err)
44
+ }
45
+
46
+ ticket := task.Ticket{
47
+ ID: "1", Key: "PAY-101",
48
+ Fields: map[string]any{
49
+ "status": "To Do",
50
+ "issueType": "Task",
51
+ "labels": []string{"coding", "backend"},
52
+ },
53
+ }
54
+ if !match(ticket) {
55
+ t.Fatal("matching ticket rejected by compiled filter")
56
+ }
57
+ }
58
+
59
+ func TestCompileFilterRejectsNonMatching(t *testing.T) {
60
+ sys := newSystemWithFake(t, &fakeACLI{})
61
+ match, err := sys.CompileFilter(config.RawValues{
62
+ "filters": map[string]any{"parentStatuses": []any{"To Do"}},
63
+ })
64
+ if err != nil {
65
+ t.Fatalf("CompileFilter failed: %v", err)
66
+ }
67
+ for _, tc := range []struct {
68
+ name string
69
+ fields map[string]any
70
+ }{
71
+ {"wrong status", map[string]any{"status": "Done", "issueType": "Task"}},
72
+ {"missing status", map[string]any{"issueType": "Task"}},
73
+ } {
74
+ t.Run(tc.name, func(t *testing.T) {
75
+ if match(task.Ticket{Key: "PAY-1", Fields: tc.fields}) {
76
+ t.Fatal("non-matching ticket accepted")
77
+ }
78
+ })
79
+ }
80
+ }
81
+
82
+ func TestCompileFilterRejectsUnknownField(t *testing.T) {
83
+ sys := newSystemWithFake(t, &fakeACLI{})
84
+ if _, err := sys.CompileFilter(config.RawValues{
85
+ "filters": map[string]any{"jql": "project = PAY"},
86
+ }); err == nil {
87
+ t.Fatal("arbitrary query/filter field accepted; workflow validation must reject it")
88
+ }
89
+ }
90
+
91
+ func TestMatchingIsInMemoryNoRequery(t *testing.T) {
92
+ // One repo poll fetches the batch; the compiled matcher then evaluates
93
+ // every ticket in memory with no per-workflow re-query.
94
+ fake := &fakeACLI{searchJSON: []byte(`[{"id":"1","key":"PAY-1","fields":{"summary":"a","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"2","key":"PAY-2","fields":{"summary":"b","status":{"name":"Done"},"issuetype":{"name":"Task"},"labels":[]}},{"id":"3","key":"PAY-3","fields":{"summary":"c","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":[]}}]`)}
95
+ base := newSystemWithFake(t, fake)
96
+ counting := &pollCountingSystem{System: base}
97
+
98
+ match, err := counting.CompileFilter(config.RawValues{
99
+ "filters": map[string]any{"parentStatuses": []any{"To Do"}},
100
+ })
101
+ if err != nil {
102
+ t.Fatal(err)
103
+ }
104
+
105
+ // The single repo poll.
106
+ batch, err := counting.Poll(context.Background())
107
+ if err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ if counting.polls != 1 {
111
+ t.Fatalf("polls = %d, want exactly 1 repo poll", counting.polls)
112
+ }
113
+ if len(batch) == 0 {
114
+ t.Fatal("poll returned no batch to match against")
115
+ }
116
+
117
+ // Matching the batch must not trigger another poll.
118
+ var matched int
119
+ for _, tk := range batch {
120
+ if match(tk) {
121
+ matched++
122
+ }
123
+ }
124
+ if counting.polls != 1 {
125
+ t.Fatalf("matching triggered %d extra task-system polls; matching must be in-memory", counting.polls-1)
126
+ }
127
+ }
128
+
129
+ func TestJiraJSONNormalization(t *testing.T) {
130
+ // The adapter normalizes Jira search JSON (status, issue type, labels,
131
+ // assignee) into task.Ticket.Fields. Package-local so it can call the
132
+ // adapter's unexported normalization directly.
133
+ // acli emits a BARE ARRAY of issue objects (no REST envelope), and the
134
+ // normalized assignee is the user's email address — the stable identity
135
+ // workflow filters match on, not the human-readable display name.
136
+ raw := []byte(`[{"id":"1","key":"PAY-101","fields":{"summary":"parent","status":{"name":"To Do"},"issuetype":{"name":"Task"},"labels":["coding"],"assignee":{"displayName":"Relay Bot","emailAddress":"relay@bot"}}}]`)
137
+
138
+ tickets, err := normalizeSearchResponse(raw)
139
+ if err != nil {
140
+ t.Fatalf("normalization failed: %v", err)
141
+ }
142
+ if len(tickets) != 1 {
143
+ t.Fatalf("tickets = %d, want 1", len(tickets))
144
+ }
145
+ tk := tickets[0]
146
+ if tk.Fields["status"] != "To Do" {
147
+ t.Fatalf("status = %v", tk.Fields["status"])
148
+ }
149
+ if tk.Fields["issueType"] != "Task" {
150
+ t.Fatalf("issueType = %v", tk.Fields["issueType"])
151
+ }
152
+ labels, ok := tk.Fields["labels"].([]string)
153
+ if !ok || len(labels) != 1 || labels[0] != "coding" {
154
+ t.Fatalf("labels = %v", tk.Fields["labels"])
155
+ }
156
+ // Raw Jira assignee object is normalized to its email address.
157
+ if tk.Fields["assignee"] != "relay@bot" {
158
+ t.Fatalf("assignee = %v, want normalized email address", tk.Fields["assignee"])
159
+ }
160
+ }
161
+
162
+ func TestJiraJSONNormalizationParsesRealAcliSearchShape(t *testing.T) {
163
+ // 9.9: parse the REAL acli wire shape captured live from
164
+ // acli jira workitem search --jql ... --fields key,summary,status,issuetype,labels,assignee --json
165
+ // Fixture stored under testdata/ so regressions in the parser are
166
+ // caught against the actual acli contract, not a hand-written envelope.
167
+ raw, err := os.ReadFile("testdata/acli_search.json")
168
+ if err != nil {
169
+ t.Fatalf("read acli fixture: %v", err)
170
+ }
171
+ tickets, err := normalizeSearchResponse(raw)
172
+ if err != nil {
173
+ t.Fatalf("normalization of real acli output failed: %v", err)
174
+ }
175
+ if len(tickets) == 0 {
176
+ t.Fatal("real acli fixture yielded no tickets")
177
+ }
178
+ for _, tk := range tickets {
179
+ if tk.Key == "" || tk.ID == "" {
180
+ t.Fatalf("ticket missing id/key: %+v", tk)
181
+ }
182
+ if tk.Fields["status"] == "" || tk.Fields["issueType"] == "" {
183
+ t.Fatalf("ticket %s missing status/issueType: %+v", tk.Key, tk.Fields)
184
+ }
185
+ if _, ok := tk.Fields["labels"].([]string); !ok {
186
+ t.Fatalf("ticket %s labels not normalized to []string: %+v", tk.Key, tk.Fields)
187
+ }
188
+ // The fixture is captured with assignee=currentUser(), so every
189
+ // entry carries an email-form normalized assignee.
190
+ a, _ := tk.Fields["assignee"].(string)
191
+ if a == "" || !strings.Contains(a, "@") {
192
+ t.Fatalf("ticket %s assignee = %q, want an email address", tk.Key, a)
193
+ }
194
+ }
195
+ }
196
+
197
+ func TestCompileFilterAssigneeMatchesEmail(t *testing.T) {
198
+ // 9.9: workflow assignee filters match the normalized EMAIL identity,
199
+ // not the display name. e2e workflow.yaml filters on the user's email.
200
+ sys := newSystemWithFake(t, &fakeACLI{})
201
+ match, err := sys.CompileFilter(config.RawValues{
202
+ "filters": map[string]any{"assignees": []any{"raj.popat@example.com"}},
203
+ })
204
+ if err != nil {
205
+ t.Fatalf("CompileFilter failed: %v", err)
206
+ }
207
+ if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "raj.popat@example.com"}}) {
208
+ t.Fatal("email assignee rejected")
209
+ }
210
+ if match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "Raj Popat"}}) {
211
+ t.Fatal("display name accepted; filters must match the normalized email")
212
+ }
213
+ }
214
+
215
+ func TestCompileFilterAssigneeMatchAndMismatch(t *testing.T) {
216
+ // Assignee is a supported structured filter dimension
217
+ // (repo-workflow-routing: "parent statuses, issue types, labels, and
218
+ // assignee"); it matches the normalized ticket's assignee field.
219
+ // NOTE: the filter key `assignees` follows the same naming as
220
+ // parentStatuses/issueTypes/labels (plural); the docs do not pin the key.
221
+ sys := newSystemWithFake(t, &fakeACLI{})
222
+ match, err := sys.CompileFilter(config.RawValues{
223
+ "filters": map[string]any{"assignees": []any{"relay-bot@example.com"}},
224
+ })
225
+ if err != nil {
226
+ t.Fatalf("CompileFilter failed: %v", err)
227
+ }
228
+ if !match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "relay-bot@example.com"}}) {
229
+ t.Fatal("matching assignee rejected")
230
+ }
231
+ if match(task.Ticket{Key: "PAY-1", Fields: map[string]any{"assignee": "someone-else@example.com"}}) {
232
+ t.Fatal("non-matching assignee accepted")
233
+ }
234
+ }
@@ -0,0 +1,60 @@
1
+ package jira
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/task"
7
+ )
8
+
9
+ // fakeClient adapts the test-local fakeACLI (transitions + raw search batch)
10
+ // to the production acli.Client seam.
11
+ type fakeClient struct {
12
+ fake *fakeACLI
13
+ }
14
+
15
+ func (f *fakeClient) Search(context.Context, string) ([]byte, error) {
16
+ return f.fake.searchJSON, nil
17
+ }
18
+
19
+ func (f *fakeClient) ValidateAssignee(context.Context, string) error { return nil }
20
+
21
+ func (f *fakeClient) ValidateStatus(context.Context, string, string) error { return nil }
22
+
23
+ func (f *fakeClient) View(context.Context, string) ([]byte, error) {
24
+ return []byte(`{"fields":{"labels":[],"subtasks":[]}}`), nil
25
+ }
26
+
27
+ func (f *fakeClient) CreateSubtask(context.Context, string, string, string) (string, string, error) {
28
+ return "", "", errNotFaked
29
+ }
30
+
31
+ func (f *fakeClient) Assign(_ context.Context, key, assignee string) error {
32
+ f.fake.assignments = append(f.fake.assignments, key+":"+assignee)
33
+ f.fake.events = append(f.fake.events, "assign")
34
+ return f.fake.assignErr
35
+ }
36
+
37
+ func (f *fakeClient) Transition(_ context.Context, key, status string) error {
38
+ f.fake.events = append(f.fake.events, "transition")
39
+ f.fake.transition(key, status)
40
+ return nil
41
+ }
42
+
43
+ func (f *fakeClient) EnsureLabel(context.Context, string, string) error { return nil }
44
+
45
+ func (f *fakeClient) UpdateDescription(context.Context, string, string) error { return nil }
46
+
47
+ func (f *fakeClient) ListComments(context.Context, string) ([]string, error) { return nil, nil }
48
+
49
+ func (f *fakeClient) AddComment(context.Context, string, string) error { return nil }
50
+
51
+ type notFakedError struct{}
52
+
53
+ func (notFakedError) Error() string { return "not faked" }
54
+
55
+ var errNotFaked = notFakedError{}
56
+
57
+ // newSystemForTest builds the adapter around the test-local fake ACLI seam.
58
+ func newSystemForTest(fake *fakeACLI) (task.System, error) {
59
+ return newSystemForCLI(&fakeClient{fake: fake})
60
+ }