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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
package workflow_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// 3.2 + 3.3: structured report contract and ValidateReport behavior per
|
|
11
|
+
// specs/structured-node-reporting.
|
|
12
|
+
|
|
13
|
+
func fullSummary() workflow.Summary {
|
|
14
|
+
return workflow.Summary{
|
|
15
|
+
Completed: "did the work",
|
|
16
|
+
Commits: "abc123",
|
|
17
|
+
NotCompleted: "None",
|
|
18
|
+
IssuesDiscovered: "None",
|
|
19
|
+
Verification: "ran tests",
|
|
20
|
+
Notes: "None",
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func fullFeedback() workflow.Feedback {
|
|
25
|
+
return workflow.Feedback{
|
|
26
|
+
ReasonForNextStep: "work done",
|
|
27
|
+
RequiredActions: "review it",
|
|
28
|
+
RelevantContext: "None",
|
|
29
|
+
ExpectedResult: "approval",
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func noneFeedback() workflow.Feedback {
|
|
34
|
+
return workflow.Feedback{
|
|
35
|
+
ReasonForNextStep: "None",
|
|
36
|
+
RequiredActions: "None",
|
|
37
|
+
RelevantContext: "None",
|
|
38
|
+
ExpectedResult: "None",
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestValidateReportAcceptsCompleteSuccess(t *testing.T) {
|
|
43
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
44
|
+
r := workflow.Report{
|
|
45
|
+
Status: workflow.OutcomeSuccess,
|
|
46
|
+
NextStep: "end",
|
|
47
|
+
Summary: fullSummary(),
|
|
48
|
+
Feedback: noneFeedback(),
|
|
49
|
+
}
|
|
50
|
+
if err := wf.ValidateReport("coding", r); err != nil {
|
|
51
|
+
t.Fatalf("valid end report rejected: %v", err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestValidateReportRequiresEverySection(t *testing.T) {
|
|
56
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
57
|
+
|
|
58
|
+
base := workflow.Report{
|
|
59
|
+
Status: workflow.OutcomeSuccess,
|
|
60
|
+
NextStep: "end",
|
|
61
|
+
Summary: fullSummary(),
|
|
62
|
+
Feedback: noneFeedback(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Empty (non-None) strings are invalid: a required section is either
|
|
66
|
+
// real content or the literal None.
|
|
67
|
+
cases := map[string]func(workflow.Report) workflow.Report{
|
|
68
|
+
"missing completed": func(r workflow.Report) workflow.Report {
|
|
69
|
+
r.Summary.Completed = ""
|
|
70
|
+
return r
|
|
71
|
+
},
|
|
72
|
+
"missing commits": func(r workflow.Report) workflow.Report {
|
|
73
|
+
r.Summary.Commits = ""
|
|
74
|
+
return r
|
|
75
|
+
},
|
|
76
|
+
"missing notCompleted": func(r workflow.Report) workflow.Report {
|
|
77
|
+
r.Summary.NotCompleted = ""
|
|
78
|
+
return r
|
|
79
|
+
},
|
|
80
|
+
"missing issuesDiscovered": func(r workflow.Report) workflow.Report {
|
|
81
|
+
r.Summary.IssuesDiscovered = ""
|
|
82
|
+
return r
|
|
83
|
+
},
|
|
84
|
+
"missing verification": func(r workflow.Report) workflow.Report {
|
|
85
|
+
r.Summary.Verification = ""
|
|
86
|
+
return r
|
|
87
|
+
},
|
|
88
|
+
"missing notes": func(r workflow.Report) workflow.Report {
|
|
89
|
+
r.Summary.Notes = ""
|
|
90
|
+
return r
|
|
91
|
+
},
|
|
92
|
+
"missing reasonForNextStep": func(r workflow.Report) workflow.Report {
|
|
93
|
+
r.Feedback.ReasonForNextStep = ""
|
|
94
|
+
return r
|
|
95
|
+
},
|
|
96
|
+
"missing requiredActions": func(r workflow.Report) workflow.Report {
|
|
97
|
+
r.Feedback.RequiredActions = ""
|
|
98
|
+
return r
|
|
99
|
+
},
|
|
100
|
+
"missing relevantContext": func(r workflow.Report) workflow.Report {
|
|
101
|
+
r.Feedback.RelevantContext = ""
|
|
102
|
+
return r
|
|
103
|
+
},
|
|
104
|
+
"missing expectedResult": func(r workflow.Report) workflow.Report {
|
|
105
|
+
r.Feedback.ExpectedResult = ""
|
|
106
|
+
return r
|
|
107
|
+
},
|
|
108
|
+
"missing nextStep": func(r workflow.Report) workflow.Report {
|
|
109
|
+
r.NextStep = ""
|
|
110
|
+
return r
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
for name, mutate := range cases {
|
|
114
|
+
t.Run(name, func(t *testing.T) {
|
|
115
|
+
if err := wf.ValidateReport("coding", mutate(base)); err == nil {
|
|
116
|
+
t.Fatal("incomplete report accepted")
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func TestValidateReportStatusValues(t *testing.T) {
|
|
123
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
124
|
+
r := workflow.Report{NextStep: "end", Summary: fullSummary(), Feedback: noneFeedback()}
|
|
125
|
+
|
|
126
|
+
r.Status = "done"
|
|
127
|
+
if err := wf.ValidateReport("coding", r); err == nil {
|
|
128
|
+
t.Fatal("status other than success|failure accepted")
|
|
129
|
+
}
|
|
130
|
+
r.Status = workflow.OutcomeFailure
|
|
131
|
+
// failure cannot select end (only coding is a failure route); use coding.
|
|
132
|
+
r.NextStep = "coding"
|
|
133
|
+
r.Feedback = fullFeedback()
|
|
134
|
+
if err := wf.ValidateReport("coding", r); err != nil {
|
|
135
|
+
t.Fatalf("valid failure report rejected: %v", err)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestValidateReportNextStepMustMatchStatusRoute(t *testing.T) {
|
|
140
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
141
|
+
|
|
142
|
+
t.Run("target legal only for other outcome", func(t *testing.T) {
|
|
143
|
+
// end is only configured for success on coding.
|
|
144
|
+
r := workflow.Report{
|
|
145
|
+
Status: workflow.OutcomeFailure,
|
|
146
|
+
NextStep: "end",
|
|
147
|
+
Summary: fullSummary(),
|
|
148
|
+
Feedback: fullFeedback(),
|
|
149
|
+
}
|
|
150
|
+
if err := wf.ValidateReport("coding", r); err == nil {
|
|
151
|
+
t.Fatal("failure report selecting a success-only target accepted")
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
t.Run("unknown target", func(t *testing.T) {
|
|
156
|
+
r := workflow.Report{
|
|
157
|
+
Status: workflow.OutcomeSuccess,
|
|
158
|
+
NextStep: "nowhere",
|
|
159
|
+
Summary: fullSummary(),
|
|
160
|
+
Feedback: fullFeedback(),
|
|
161
|
+
}
|
|
162
|
+
err := wf.ValidateReport("coding", r)
|
|
163
|
+
if err == nil {
|
|
164
|
+
t.Fatal("next step naming an unconfigured target accepted")
|
|
165
|
+
}
|
|
166
|
+
// The error should surface the legal choices.
|
|
167
|
+
if !strings.Contains(err.Error(), "end") {
|
|
168
|
+
t.Fatalf("error %q does not list the legal choice end", err)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
t.Run("revisit loop route accepted", func(t *testing.T) {
|
|
173
|
+
r := workflow.Report{
|
|
174
|
+
Status: workflow.OutcomeFailure,
|
|
175
|
+
NextStep: "coding",
|
|
176
|
+
Summary: fullSummary(),
|
|
177
|
+
Feedback: fullFeedback(),
|
|
178
|
+
}
|
|
179
|
+
if err := wf.ValidateReport("coding", r); err != nil {
|
|
180
|
+
t.Fatalf("failure self-loop rejected: %v", err)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func TestValidateReportEndRequiresNoneFeedback(t *testing.T) {
|
|
186
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
187
|
+
|
|
188
|
+
t.Run("end with all-None feedback accepted", func(t *testing.T) {
|
|
189
|
+
r := workflow.Report{
|
|
190
|
+
Status: workflow.OutcomeSuccess,
|
|
191
|
+
NextStep: "end",
|
|
192
|
+
Summary: fullSummary(),
|
|
193
|
+
Feedback: noneFeedback(),
|
|
194
|
+
}
|
|
195
|
+
if err := wf.ValidateReport("coding", r); err != nil {
|
|
196
|
+
t.Fatalf("end report with None feedback rejected: %v", err)
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
t.Run("end with feedback content rejected", func(t *testing.T) {
|
|
201
|
+
r := workflow.Report{
|
|
202
|
+
Status: workflow.OutcomeSuccess,
|
|
203
|
+
NextStep: "end",
|
|
204
|
+
Summary: fullSummary(),
|
|
205
|
+
Feedback: fullFeedback(),
|
|
206
|
+
}
|
|
207
|
+
if err := wf.ValidateReport("coding", r); err == nil {
|
|
208
|
+
t.Fatal("end report carrying feedback accepted; end has no mailbox")
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
t.Run("end report still requires summary", func(t *testing.T) {
|
|
213
|
+
r := workflow.Report{
|
|
214
|
+
Status: workflow.OutcomeSuccess,
|
|
215
|
+
NextStep: "end",
|
|
216
|
+
Summary: workflow.Summary{},
|
|
217
|
+
Feedback: noneFeedback(),
|
|
218
|
+
}
|
|
219
|
+
if err := wf.ValidateReport("coding", r); err == nil {
|
|
220
|
+
t.Fatal("end report without summary accepted")
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func TestValidateReportCalledForBothNodeTypes(t *testing.T) {
|
|
226
|
+
hitlYaml := strings.Replace(minimalValid, " type: agent", " type: hitl", 1)
|
|
227
|
+
wf := parse(t, "basicFlow", hitlYaml)
|
|
228
|
+
|
|
229
|
+
valid := workflow.Report{
|
|
230
|
+
Status: workflow.OutcomeSuccess,
|
|
231
|
+
NextStep: "end",
|
|
232
|
+
Summary: fullSummary(),
|
|
233
|
+
Feedback: noneFeedback(),
|
|
234
|
+
}
|
|
235
|
+
if err := wf.ValidateReport("coding", valid); err != nil {
|
|
236
|
+
t.Fatalf("HITL node report rejected: %v", err)
|
|
237
|
+
}
|
|
238
|
+
invalid := valid
|
|
239
|
+
invalid.NextStep = "nowhere"
|
|
240
|
+
if err := wf.ValidateReport("coding", invalid); err == nil {
|
|
241
|
+
t.Fatal("invalid HITL report accepted; ValidateReport is shared by agent and HITL nodes")
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func TestValidateReportNoneMeansIntentionallyEmpty(t *testing.T) {
|
|
246
|
+
wf := parse(t, "basicFlow", minimalValid)
|
|
247
|
+
s := fullSummary()
|
|
248
|
+
s.IssuesDiscovered = "None"
|
|
249
|
+
s.NotCompleted = "None"
|
|
250
|
+
r := workflow.Report{
|
|
251
|
+
Status: workflow.OutcomeSuccess,
|
|
252
|
+
NextStep: "end",
|
|
253
|
+
Summary: s,
|
|
254
|
+
Feedback: noneFeedback(),
|
|
255
|
+
}
|
|
256
|
+
if err := wf.ValidateReport("coding", r); err != nil {
|
|
257
|
+
t.Fatalf("report with literal None sections rejected: %v", err)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package workflow
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sync"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// ActiveRuns reports whether any run of a workflow is active.
|
|
10
|
+
type ActiveRuns interface {
|
|
11
|
+
HasActiveWorkflow(context.Context, string) (bool, error)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// RepoLookup reports whether a repo is registered.
|
|
15
|
+
type RepoLookup interface {
|
|
16
|
+
Exists(string) bool
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Service manages workflow submission, removal, and lookup over the store
|
|
20
|
+
// and registry.
|
|
21
|
+
type Service struct {
|
|
22
|
+
store *Store
|
|
23
|
+
reg *Registry
|
|
24
|
+
repos RepoLookup
|
|
25
|
+
active ActiveRuns
|
|
26
|
+
// Gate, when non-nil, is the lifecycle mutex shared with
|
|
27
|
+
// RunManager.EnsureRun (design.md decision 23): Submit/Remove hold it
|
|
28
|
+
// while checking active runs and swapping disk + in-memory definitions,
|
|
29
|
+
// so a run never starts against a workflow being replaced or removed.
|
|
30
|
+
// A plain *sync.Mutex — no lock service.
|
|
31
|
+
Gate *sync.Mutex
|
|
32
|
+
// Rebind, when non-nil, is invoked under Gate after the in-memory
|
|
33
|
+
// workflow registry changes (submit or remove). The composition root
|
|
34
|
+
// wires it to repo.Registry.BindWorkflows so poller/router bindings are
|
|
35
|
+
// rebuilt atomically with the workflow swap (spec: "Repo.Workflows
|
|
36
|
+
// bindings are rebuilt on submit/remove/startup"). Nil in tests that
|
|
37
|
+
// do not wire repos.
|
|
38
|
+
Rebind func() error
|
|
39
|
+
// ValidateTaskConfig validates the workflow against each referenced repo's
|
|
40
|
+
// task system before storage. The composition root supplies this callback;
|
|
41
|
+
// keeping it here avoids widening RepoLookup beyond its documented query.
|
|
42
|
+
ValidateTaskConfig func(context.Context, *Workflow) error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func NewService(store *Store, active ActiveRuns, repos RepoLookup) *Service {
|
|
46
|
+
return &Service{store: store, reg: &Registry{}, active: active, repos: repos}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Registry exposes the in-memory workflow set for wiring (startup load).
|
|
50
|
+
func (s *Service) Registry() *Registry {
|
|
51
|
+
return s.reg
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Submit creates or replaces a workflow. It parses, validates, and checks
|
|
55
|
+
// repos and active runs before atomically replacing the file; the in-memory
|
|
56
|
+
// replacement cannot fail afterward. There is no workflow versioning.
|
|
57
|
+
//
|
|
58
|
+
// When Gate is set, the ENTIRE Submit operation (parse, repo validation,
|
|
59
|
+
// active-run check, disk + in-memory swap, binding rebuild) holds the
|
|
60
|
+
// shared lifecycle mutex so no concurrent EnsureRun can resolve a stale
|
|
61
|
+
// definition and no concurrent repo registration can invalidate the
|
|
62
|
+
// repo-existence check (design.md decision 23).
|
|
63
|
+
func (s *Service) Submit(ctx context.Context, yamlBytes []byte) (*Workflow, error) {
|
|
64
|
+
if s.Gate != nil {
|
|
65
|
+
s.Gate.Lock()
|
|
66
|
+
defer s.Gate.Unlock()
|
|
67
|
+
}
|
|
68
|
+
wf, err := Parse("", yamlBytes)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, err
|
|
71
|
+
}
|
|
72
|
+
if err := wf.Validate(); err != nil {
|
|
73
|
+
return nil, err
|
|
74
|
+
}
|
|
75
|
+
for _, repo := range wf.Repos {
|
|
76
|
+
if !s.repos.Exists(repo) {
|
|
77
|
+
return nil, fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repo)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if s.ValidateTaskConfig != nil {
|
|
81
|
+
if err := s.ValidateTaskConfig(ctx, wf); err != nil {
|
|
82
|
+
return nil, err
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
active, err := s.active.HasActiveWorkflow(ctx, wf.Name)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return nil, fmt.Errorf("check active runs for workflow %q: %w", wf.Name, err)
|
|
88
|
+
}
|
|
89
|
+
if active {
|
|
90
|
+
return nil, fmt.Errorf("workflow %q has active runs; replacement is rejected", wf.Name)
|
|
91
|
+
}
|
|
92
|
+
if err := s.store.Put(wf.Name, yamlBytes); err != nil {
|
|
93
|
+
return nil, err
|
|
94
|
+
}
|
|
95
|
+
s.reg.Replace(wf)
|
|
96
|
+
if s.Rebind != nil {
|
|
97
|
+
if err := s.Rebind(); err != nil {
|
|
98
|
+
return nil, fmt.Errorf("rebind workflows for %q: %w", wf.Name, err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return wf, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Remove deletes a workflow definition. Removal is rejected while any run
|
|
105
|
+
// of the workflow is active.
|
|
106
|
+
func (s *Service) Remove(ctx context.Context, name string) error {
|
|
107
|
+
if s.Gate != nil {
|
|
108
|
+
s.Gate.Lock()
|
|
109
|
+
defer s.Gate.Unlock()
|
|
110
|
+
}
|
|
111
|
+
active, err := s.active.HasActiveWorkflow(ctx, name)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return fmt.Errorf("check active runs for workflow %q: %w", name, err)
|
|
114
|
+
}
|
|
115
|
+
if active {
|
|
116
|
+
return fmt.Errorf("workflow %q has active runs; removal is rejected", name)
|
|
117
|
+
}
|
|
118
|
+
if err := s.store.Remove(name); err != nil {
|
|
119
|
+
return err
|
|
120
|
+
}
|
|
121
|
+
s.reg.Remove(name)
|
|
122
|
+
if s.Rebind != nil {
|
|
123
|
+
if err := s.Rebind(); err != nil {
|
|
124
|
+
return fmt.Errorf("rebind workflows after removing %q: %w", name, err)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return nil
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Get returns the workflow from the registry.
|
|
131
|
+
func (s *Service) Get(name string) (*Workflow, error) {
|
|
132
|
+
wf, ok := s.reg.Get(name)
|
|
133
|
+
if !ok {
|
|
134
|
+
return nil, fmt.Errorf("workflow %q not found", name)
|
|
135
|
+
}
|
|
136
|
+
return wf, nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// List returns all registered workflows.
|
|
140
|
+
func (s *Service) List() []*Workflow {
|
|
141
|
+
return s.reg.List()
|
|
142
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
package workflow
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"sort"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Store persists workflow definitions as YAML files under Dir. The workflow
|
|
15
|
+
// file is the durable definition.
|
|
16
|
+
type Store struct {
|
|
17
|
+
Dir string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (s *Store) path(name string) string {
|
|
21
|
+
return filepath.Join(s.Dir, name+".yaml")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// LoadAll reads and parses every stored workflow file.
|
|
25
|
+
func (s *Store) LoadAll() ([]*Workflow, error) {
|
|
26
|
+
entries, err := os.ReadDir(s.Dir)
|
|
27
|
+
if err != nil {
|
|
28
|
+
if os.IsNotExist(err) {
|
|
29
|
+
return nil, nil
|
|
30
|
+
}
|
|
31
|
+
return nil, fmt.Errorf("read workflow dir %s: %w", s.Dir, err)
|
|
32
|
+
}
|
|
33
|
+
var out []*Workflow
|
|
34
|
+
for _, e := range entries {
|
|
35
|
+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
name := strings.TrimSuffix(e.Name(), ".yaml")
|
|
39
|
+
wf, err := s.Get(name)
|
|
40
|
+
if err != nil {
|
|
41
|
+
return nil, err
|
|
42
|
+
}
|
|
43
|
+
out = append(out, wf)
|
|
44
|
+
}
|
|
45
|
+
return out, nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get reads and parses one stored workflow by name.
|
|
49
|
+
func (s *Store) Get(name string) (*Workflow, error) {
|
|
50
|
+
raw, err := os.ReadFile(s.path(name))
|
|
51
|
+
if err != nil {
|
|
52
|
+
return nil, fmt.Errorf("read workflow %q: %w", name, err)
|
|
53
|
+
}
|
|
54
|
+
return Parse(name, raw)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Put atomically writes the workflow file with mode 0644.
|
|
58
|
+
func (s *Store) Put(name string, yamlBytes []byte) error {
|
|
59
|
+
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
|
|
60
|
+
return fmt.Errorf("create workflow dir %s: %w", s.Dir, err)
|
|
61
|
+
}
|
|
62
|
+
if err := config.WriteAtomic(s.path(name), yamlBytes, 0o644); err != nil {
|
|
63
|
+
return fmt.Errorf("store workflow %q: %w", name, err)
|
|
64
|
+
}
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Remove deletes the workflow file.
|
|
69
|
+
func (s *Store) Remove(name string) error {
|
|
70
|
+
if err := os.Remove(s.path(name)); err != nil {
|
|
71
|
+
return fmt.Errorf("remove workflow %q: %w", name, err)
|
|
72
|
+
}
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Registry is the in-memory workflow set. The registry has no repo index;
|
|
77
|
+
// repo bindings are the only derived repo-to-workflow index.
|
|
78
|
+
type Registry struct {
|
|
79
|
+
mu sync.RWMutex
|
|
80
|
+
byID map[string]*Workflow
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func (r *Registry) get(name string) (*Workflow, bool) {
|
|
84
|
+
r.mu.RLock()
|
|
85
|
+
defer r.mu.RUnlock()
|
|
86
|
+
wf, ok := r.byID[name]
|
|
87
|
+
return wf, ok
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Get returns the workflow by name.
|
|
91
|
+
func (r *Registry) Get(name string) (*Workflow, bool) {
|
|
92
|
+
return r.get(name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// List returns all registered workflows sorted by name.
|
|
96
|
+
func (r *Registry) List() []*Workflow {
|
|
97
|
+
r.mu.RLock()
|
|
98
|
+
defer r.mu.RUnlock()
|
|
99
|
+
out := make([]*Workflow, 0, len(r.byID))
|
|
100
|
+
for _, wf := range r.byID {
|
|
101
|
+
out = append(out, wf)
|
|
102
|
+
}
|
|
103
|
+
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ReferencesRepo reports whether any registered workflow lists the repo.
|
|
108
|
+
func (r *Registry) ReferencesRepo(repo string) bool {
|
|
109
|
+
r.mu.RLock()
|
|
110
|
+
defer r.mu.RUnlock()
|
|
111
|
+
for _, wf := range r.byID {
|
|
112
|
+
for _, rr := range wf.Repos {
|
|
113
|
+
if rr == repo {
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Replace creates or replaces the workflow in the registry.
|
|
122
|
+
func (r *Registry) Replace(wf *Workflow) {
|
|
123
|
+
r.mu.Lock()
|
|
124
|
+
defer r.mu.Unlock()
|
|
125
|
+
if r.byID == nil {
|
|
126
|
+
r.byID = map[string]*Workflow{}
|
|
127
|
+
}
|
|
128
|
+
r.byID[wf.Name] = wf
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Remove deletes the workflow from the registry.
|
|
132
|
+
func (r *Registry) Remove(name string) {
|
|
133
|
+
r.mu.Lock()
|
|
134
|
+
defer r.mu.Unlock()
|
|
135
|
+
delete(r.byID, name)
|
|
136
|
+
}
|