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,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
+ }