relay-flow 0.3.7-alpha → 0.3.8-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 +5 -5
- package/cmd/relay-flow/observability_test.go +28 -0
- package/cmd/relay-flow/render.go +20 -4
- package/cmd/relay-flow/scenario_test.go +15 -13
- package/cmd/relay-flow/serve.go +116 -103
- package/internal/execution/goworkflows/activities.go +6 -0
- package/internal/execution/temporal/activities.go +6 -0
- package/internal/execution/temporal/recovery.go +4 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/recover/recover.go +4 -0
- package/internal/repo/binding_test.go +92 -0
- package/internal/repo/poller.go +3 -0
- package/internal/repo/repo.go +82 -6
- package/internal/run/manager.go +41 -0
- package/internal/run/run_manager_test.go +56 -0
- package/internal/server/observability.go +1 -1
- package/internal/task/beads/beads.go +42 -4
- package/internal/task/beads/beads_test.go +15 -0
- package/internal/task/factory.go +41 -2
- package/internal/task/jira/jira.go +52 -28
- package/internal/workflow/service.go +53 -0
- package/internal/workflow/store.go +413 -19
- package/internal/workflow/store_test.go +235 -0
- package/internal/workflow/workflow.go +71 -0
- package/package.json +1 -1
package/internal/repo/repo.go
CHANGED
|
@@ -31,8 +31,12 @@ type Repo struct {
|
|
|
31
31
|
Path string
|
|
32
32
|
TaskConfig config.RawValues
|
|
33
33
|
TaskSystem task.System
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// TaskSystemError is set when local startup construction could not create
|
|
35
|
+
// a usable adapter. The repo remains visible for diagnostics and repair,
|
|
36
|
+
// while only workflows referencing it are isolated.
|
|
37
|
+
TaskSystemError error
|
|
38
|
+
Workflows []WorkflowBinding
|
|
39
|
+
bindingsMu sync.RWMutex
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
func (r *Repo) Info() Info {
|
|
@@ -95,25 +99,97 @@ func (r *Registry) Remove(name string) {
|
|
|
95
99
|
// BindWorkflows rebuilds the derived Repo.Workflows index from the given
|
|
96
100
|
// workflows. Each repo that a workflow lists gets a binding with the
|
|
97
101
|
// matcher compiled by that repo's task system. Repos not listed by a
|
|
98
|
-
// workflow keep no binding for it.
|
|
102
|
+
// workflow keep no binding for it. The strict form is used by submission,
|
|
103
|
+
// where a binding error must reject the candidate before it is stored.
|
|
99
104
|
func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
|
|
105
|
+
_, err := r.bindWorkflows(workflows, false)
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// BindingIssue identifies a workflow that could not be safely published.
|
|
110
|
+
// Startup uses the isolated form so one invalid workflow does not prevent
|
|
111
|
+
// unrelated workflows from being bound.
|
|
112
|
+
type BindingIssue struct {
|
|
113
|
+
Workflow *workflow.Workflow
|
|
114
|
+
Error error
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// BindWorkflowsIsolated rebuilds all bindings while isolating workflows whose
|
|
118
|
+
// referenced repo or matcher cannot be built. The returned issues are
|
|
119
|
+
// diagnostics only; valid workflows are still published atomically.
|
|
120
|
+
func (r *Registry) BindWorkflowsIsolated(workflows []*workflow.Workflow) []BindingIssue {
|
|
121
|
+
issues, _ := r.bindWorkflows(workflows, true)
|
|
122
|
+
return issues
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func (r *Registry) bindWorkflows(workflows []*workflow.Workflow, isolate bool) ([]BindingIssue, error) {
|
|
100
126
|
type binding struct {
|
|
101
127
|
wf *workflow.Workflow
|
|
102
128
|
match func(task.Ticket) bool
|
|
103
129
|
}
|
|
104
130
|
byRepo := map[string][]binding{}
|
|
131
|
+
issues := []BindingIssue{}
|
|
105
132
|
for _, wf := range workflows {
|
|
133
|
+
if wf == nil || !wf.IsRoutable() {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
failed := false
|
|
106
137
|
for _, repoName := range wf.Repos {
|
|
107
138
|
rp, ok := r.Get(repoName)
|
|
108
139
|
if !ok {
|
|
109
|
-
|
|
140
|
+
err := fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
|
|
141
|
+
if !isolate {
|
|
142
|
+
return nil, err
|
|
143
|
+
}
|
|
144
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
145
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
146
|
+
failed = true
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
if rp.TaskSystem == nil {
|
|
150
|
+
reason := rp.TaskSystemError
|
|
151
|
+
if reason == nil {
|
|
152
|
+
reason = fmt.Errorf("repo %q task system is unavailable", repoName)
|
|
153
|
+
}
|
|
154
|
+
err := fmt.Errorf("workflow %q repo %q: task system unavailable: %w", wf.Name, repoName, reason)
|
|
155
|
+
if !isolate {
|
|
156
|
+
return nil, err
|
|
157
|
+
}
|
|
158
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
159
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
160
|
+
failed = true
|
|
161
|
+
break
|
|
110
162
|
}
|
|
111
163
|
match, err := rp.TaskSystem.CompileFilter(wf.TaskConfig)
|
|
112
164
|
if err != nil {
|
|
113
|
-
|
|
165
|
+
err = fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
|
|
166
|
+
if !isolate {
|
|
167
|
+
return nil, err
|
|
168
|
+
}
|
|
169
|
+
wf.MarkBlocked(err.Error(), wf.RepairCommand)
|
|
170
|
+
issues = append(issues, BindingIssue{Workflow: wf, Error: err})
|
|
171
|
+
failed = true
|
|
172
|
+
break
|
|
114
173
|
}
|
|
115
174
|
byRepo[repoName] = append(byRepo[repoName], binding{wf: wf, match: match})
|
|
116
175
|
}
|
|
176
|
+
if failed {
|
|
177
|
+
// A workflow is all-or-nothing across its referenced repositories;
|
|
178
|
+
// never leave a partial route for it.
|
|
179
|
+
for repoName, binds := range byRepo {
|
|
180
|
+
filtered := binds[:0]
|
|
181
|
+
for _, b := range binds {
|
|
182
|
+
if b.wf != wf {
|
|
183
|
+
filtered = append(filtered, b)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if len(filtered) == 0 {
|
|
187
|
+
delete(byRepo, repoName)
|
|
188
|
+
} else {
|
|
189
|
+
byRepo[repoName] = filtered
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
117
193
|
}
|
|
118
194
|
r.mu.Lock()
|
|
119
195
|
defer r.mu.Unlock()
|
|
@@ -128,5 +204,5 @@ func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
|
|
|
128
204
|
rp.Workflows = next
|
|
129
205
|
rp.bindingsMu.Unlock()
|
|
130
206
|
}
|
|
131
|
-
return nil
|
|
207
|
+
return issues, nil
|
|
132
208
|
}
|
package/internal/run/manager.go
CHANGED
|
@@ -64,10 +64,48 @@ func newerRun(candidate, current Run) bool {
|
|
|
64
64
|
// missing claimed run, then ensures the durable run with a value snapshot of
|
|
65
65
|
// the workflow.
|
|
66
66
|
func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.Workflow, ticket task.Ticket) error {
|
|
67
|
+
if rp == nil {
|
|
68
|
+
return fmt.Errorf("ensure run: repository is unavailable")
|
|
69
|
+
}
|
|
70
|
+
if rp.TaskSystem == nil {
|
|
71
|
+
if rp.TaskSystemError != nil {
|
|
72
|
+
return fmt.Errorf("ensure run repo %q: task system unavailable: %w", rp.Name, rp.TaskSystemError)
|
|
73
|
+
}
|
|
74
|
+
return fmt.Errorf("ensure run repo %q: task system unavailable", rp.Name)
|
|
75
|
+
}
|
|
76
|
+
if wf == nil {
|
|
77
|
+
return fmt.Errorf("ensure run repo %q: workflow is unavailable", rp.Name)
|
|
78
|
+
}
|
|
67
79
|
if m.Gate != nil {
|
|
68
80
|
m.Gate.Lock()
|
|
69
81
|
defer m.Gate.Unlock()
|
|
70
82
|
}
|
|
83
|
+
// Poll routing resolves a binding before entering this method. Resolve
|
|
84
|
+
// the registry again while holding the same lifecycle gate used by submit
|
|
85
|
+
// and remove so a concurrent replacement cannot create a run from the old
|
|
86
|
+
// workflow snapshot.
|
|
87
|
+
if m.Workflows != nil {
|
|
88
|
+
current, ok := m.Workflows.Get(wf.Name)
|
|
89
|
+
if !ok {
|
|
90
|
+
return fmt.Errorf("ensure run repo %q: workflow %q is no longer stored", rp.Name, wf.Name)
|
|
91
|
+
}
|
|
92
|
+
wf = current
|
|
93
|
+
}
|
|
94
|
+
if !wf.IsRoutable() {
|
|
95
|
+
return fmt.Errorf("ensure run repo %q: workflow %q is %s and cannot start a new run", rp.Name, wf.Name, wf.Status)
|
|
96
|
+
}
|
|
97
|
+
if m.Workflows != nil {
|
|
98
|
+
targeted := false
|
|
99
|
+
for _, repoName := range wf.Repos {
|
|
100
|
+
if repoName == rp.Name {
|
|
101
|
+
targeted = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if !targeted {
|
|
106
|
+
return fmt.Errorf("ensure run repo %q: workflow %q no longer targets this repository", rp.Name, wf.Name)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
71
109
|
id := identity.NewRunID(rp.Name, wf.Name, ticket.Key)
|
|
72
110
|
claimed := false
|
|
73
111
|
for _, c := range ticket.WorkflowClaims {
|
|
@@ -214,6 +252,9 @@ func (m *RunManager) RestartByTicket(ctx context.Context, ticket string) (Run, e
|
|
|
214
252
|
if !ok {
|
|
215
253
|
return Run{}, fmt.Errorf("%w: workflow %q for canceled run %s is no longer stored", ErrRestartConflict, previous.Workflow, previous.ID)
|
|
216
254
|
}
|
|
255
|
+
if !wf.IsRoutable() {
|
|
256
|
+
return Run{}, fmt.Errorf("%w: workflow %q is %s and cannot be restarted", ErrRestartConflict, wf.Name, wf.Status)
|
|
257
|
+
}
|
|
217
258
|
bound := false
|
|
218
259
|
for _, name := range wf.Repos {
|
|
219
260
|
if name == previous.Repo {
|
|
@@ -259,6 +259,62 @@ func TestDeterministicRunID(t *testing.T) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
func TestEnsureRunReResolvesWorkflowUnderLifecycleRegistry(t *testing.T) {
|
|
263
|
+
log := newEventLog()
|
|
264
|
+
sys := &recordingSystem{log: log}
|
|
265
|
+
exec := &fakeExecutor{log: log}
|
|
266
|
+
old := testWorkflow("basicFlow")
|
|
267
|
+
latest := testWorkflow("basicFlow")
|
|
268
|
+
latest.Status = workflow.HealthOutdated
|
|
269
|
+
workflows := &workflow.Registry{}
|
|
270
|
+
workflows.Replace(latest)
|
|
271
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}, Workflows: workflows}
|
|
272
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), old, task.Ticket{Key: "PAY-101"}); err == nil {
|
|
273
|
+
t.Fatal("EnsureRun used stale routable workflow despite registry replacement")
|
|
274
|
+
}
|
|
275
|
+
if len(exec.ensures) != 0 || len(log.all()) != 0 {
|
|
276
|
+
t.Fatalf("stale workflow caused external work: ensures=%d events=%v", len(exec.ensures), log.all())
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func TestEnsureRunRejectsOutdatedWorkflow(t *testing.T) {
|
|
281
|
+
log := newEventLog()
|
|
282
|
+
sys := &recordingSystem{log: log}
|
|
283
|
+
exec := &fakeExecutor{log: log}
|
|
284
|
+
wf := testWorkflow("basicFlow")
|
|
285
|
+
wf.Status = workflow.HealthOutdated
|
|
286
|
+
m := &run.RunManager{Executor: exec, Runs: &fakeQueries{}}
|
|
287
|
+
if err := m.EnsureRun(context.Background(), testRepo(sys), wf, task.Ticket{Key: "PAY-101"}); err == nil {
|
|
288
|
+
t.Fatal("EnsureRun accepted an outdated workflow")
|
|
289
|
+
}
|
|
290
|
+
if len(exec.ensures) != 0 || len(log.all()) != 0 {
|
|
291
|
+
t.Fatalf("outdated workflow caused external work: ensures=%d events=%v", len(exec.ensures), log.all())
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func TestRestartByTicketRejectsOutdatedWorkflow(t *testing.T) {
|
|
296
|
+
log := newEventLog()
|
|
297
|
+
sys := &recordingSystem{log: log}
|
|
298
|
+
exec := &fakeExecutor{log: log}
|
|
299
|
+
wf := testWorkflow("basicFlow")
|
|
300
|
+
wf.Status = workflow.HealthOutdated
|
|
301
|
+
repos := repo.NewRegistry()
|
|
302
|
+
repos.Replace(testRepo(sys))
|
|
303
|
+
workflows := &workflow.Registry{}
|
|
304
|
+
workflows.Replace(wf)
|
|
305
|
+
m := &run.RunManager{
|
|
306
|
+
Executor: exec,
|
|
307
|
+
Runs: &fakeQueries{byTicket: map[string]run.Run{"PAY-101": {ID: "old", Repo: "payments", Workflow: "basicFlow", Ticket: task.TicketRef{Key: "PAY-101"}, State: run.StateCanceled}}},
|
|
308
|
+
Repos: repos, Workflows: workflows,
|
|
309
|
+
}
|
|
310
|
+
if _, err := m.RestartByTicket(context.Background(), "PAY-101"); err == nil {
|
|
311
|
+
t.Fatal("RestartByTicket accepted an outdated workflow")
|
|
312
|
+
}
|
|
313
|
+
if len(exec.ensures) != 0 {
|
|
314
|
+
t.Fatalf("outdated restart ensured %d runs", len(exec.ensures))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
262
318
|
func TestRestartByTicketCreatesNumericFreshAttempt(t *testing.T) {
|
|
263
319
|
log := newEventLog()
|
|
264
320
|
sys := &recordingSystem{log: log}
|
|
@@ -105,5 +105,5 @@ func BuildWorkflowDetail(wf *workflow.Workflow, runs []run.Run) WorkflowDetail {
|
|
|
105
105
|
active++
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
return WorkflowDetail{Workflow: wf, Valid: wf != nil, ActiveRuns: active, RecentRuns: recent}
|
|
108
|
+
return WorkflowDetail{Workflow: wf, Valid: wf != nil && wf.IsRoutable(), ActiveRuns: active, RecentRuns: recent}
|
|
109
109
|
}
|
|
@@ -105,6 +105,7 @@ func init() {
|
|
|
105
105
|
DefaultConfig: DefaultConfig,
|
|
106
106
|
ValidateTextConfig: validateTextConfig,
|
|
107
107
|
New: newSystem,
|
|
108
|
+
NewLocal: newSystemLocal,
|
|
108
109
|
})
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -184,6 +185,14 @@ func (s *system) AgentEnv() map[string]string {
|
|
|
184
185
|
// workspace must be supplied by repoConfig; a root-level value never satisfies
|
|
185
186
|
// the required repo-scoped key.
|
|
186
187
|
func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
188
|
+
beadsDir, err := configuredBeadsDir(rootConfig, repoConfig)
|
|
189
|
+
if err != nil {
|
|
190
|
+
return "", err
|
|
191
|
+
}
|
|
192
|
+
return canonicalBeadsDir(beadsDir)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func configuredBeadsDir(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
187
196
|
var root Config
|
|
188
197
|
if err := config.DecodeStrict(rootConfig, &root); err != nil {
|
|
189
198
|
return "", fmt.Errorf("root task config: %w", err)
|
|
@@ -195,7 +204,7 @@ func beadsTaskScopeKey(rootConfig, repoConfig config.RawValues) (string, error)
|
|
|
195
204
|
if strings.TrimSpace(repo.BeadsDir) == "" {
|
|
196
205
|
return "", errors.New("beads task scope requires repo beadsDir")
|
|
197
206
|
}
|
|
198
|
-
return
|
|
207
|
+
return repo.BeadsDir, nil
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
func canonicalBeadsDir(value string) (string, error) {
|
|
@@ -221,9 +230,31 @@ func canonicalBeadsDir(value string) (string, error) {
|
|
|
221
230
|
return filepath.Clean(resolved), nil
|
|
222
231
|
}
|
|
223
232
|
|
|
233
|
+
func localBeadsDir(rootConfig, repoConfig config.RawValues) (string, error) {
|
|
234
|
+
configured, err := configuredBeadsDir(rootConfig, repoConfig)
|
|
235
|
+
if err != nil {
|
|
236
|
+
return "", err
|
|
237
|
+
}
|
|
238
|
+
abs, err := filepath.Abs(strings.TrimSpace(configured))
|
|
239
|
+
if err != nil {
|
|
240
|
+
return "", fmt.Errorf("resolve beadsDir %q: %w", configured, err)
|
|
241
|
+
}
|
|
242
|
+
return filepath.Clean(abs), nil
|
|
243
|
+
}
|
|
244
|
+
|
|
224
245
|
// newSystem constructs and probes a repo-bound Beads task system. It does not
|
|
225
246
|
// initialize a workspace or start any Beads/Dolt server.
|
|
226
247
|
func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
248
|
+
return newSystemWithProbe(ctx, spec, true)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// newSystemLocal constructs only local Beads state. Probe is intentionally
|
|
252
|
+
// deferred to workflow submission so restart can still expose management APIs.
|
|
253
|
+
func newSystemLocal(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
254
|
+
return newSystemWithProbe(ctx, spec, false)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func newSystemWithProbe(ctx context.Context, spec task.RepoSpec, probe bool) (task.System, error) {
|
|
227
258
|
if strings.TrimSpace(spec.Name) == "" {
|
|
228
259
|
return nil, errors.New("beads: repo name is required")
|
|
229
260
|
}
|
|
@@ -233,7 +264,12 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
|
233
264
|
}
|
|
234
265
|
// Validate the repo-scoped key before merging with root values. This keeps a
|
|
235
266
|
// root beadsDir from silently satisfying repository registration.
|
|
236
|
-
beadsDir
|
|
267
|
+
var beadsDir string
|
|
268
|
+
if probe {
|
|
269
|
+
beadsDir, err = beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
|
|
270
|
+
} else {
|
|
271
|
+
beadsDir, err = localBeadsDir(spec.RootConfig, spec.RepoConfig)
|
|
272
|
+
}
|
|
237
273
|
if err != nil {
|
|
238
274
|
return nil, fmt.Errorf("beads repo %q: %w", spec.Name, err)
|
|
239
275
|
}
|
|
@@ -246,8 +282,10 @@ func newSystem(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
|
246
282
|
return nil, fmt.Errorf("beads repo %q config: %w", spec.Name, err)
|
|
247
283
|
}
|
|
248
284
|
cli := bdcli.New(spec.Path, beadsDir)
|
|
249
|
-
if
|
|
250
|
-
|
|
285
|
+
if probe {
|
|
286
|
+
if err := cli.Probe(ctx); err != nil {
|
|
287
|
+
return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
|
|
288
|
+
}
|
|
251
289
|
}
|
|
252
290
|
return &system{
|
|
253
291
|
cli: cli,
|
|
@@ -16,6 +16,21 @@ import (
|
|
|
16
16
|
"github.com/rajpopat27/relay-flow/internal/task/beads/bdcli"
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
func TestBeadsLocalFactoryDoesNotProbeMissingWorkspace(t *testing.T) {
|
|
20
|
+
missing := filepath.Join(t.TempDir(), "not-yet-available")
|
|
21
|
+
sys, err := task.NewLocal(context.Background(), "beads", task.RepoSpec{
|
|
22
|
+
Name: "payments",
|
|
23
|
+
Path: t.TempDir(),
|
|
24
|
+
RepoConfig: config.RawValues{"beadsDir": missing},
|
|
25
|
+
})
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatalf("NewLocal returned error for a missing remote workspace: %v", err)
|
|
28
|
+
}
|
|
29
|
+
if sys == nil {
|
|
30
|
+
t.Fatal("NewLocal returned nil task system")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
func TestBeadsFactoryIsRegisteredWithBeadsDirRequirement(t *testing.T) {
|
|
20
35
|
if !hasString(task.Names(), "beads") {
|
|
21
36
|
t.Fatalf("task plugins = %v, want beads", task.Names())
|
package/internal/task/factory.go
CHANGED
|
@@ -44,7 +44,13 @@ type Factory struct {
|
|
|
44
44
|
Auth func(context.Context, []string, io.Reader) error
|
|
45
45
|
DefaultConfig func() config.RawValues
|
|
46
46
|
ValidateTextConfig func(config.RawValues) error
|
|
47
|
-
New
|
|
47
|
+
// New constructs a fully validated repo-bound system. It is used by repo
|
|
48
|
+
// registration, where connectivity must be confirmed immediately.
|
|
49
|
+
New func(context.Context, RepoSpec) (System, error)
|
|
50
|
+
// NewLocal constructs only the local adapter state needed for startup. It
|
|
51
|
+
// must not probe remote services; submission-time validation owns those
|
|
52
|
+
// checks. Factories without a local constructor retain the old behavior.
|
|
53
|
+
NewLocal func(context.Context, RepoSpec) (System, error)
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
var (
|
|
@@ -72,16 +78,49 @@ func lookup(name string) (Factory, error) {
|
|
|
72
78
|
return f, nil
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
// New constructs the repo-bound task System for the named plugin
|
|
81
|
+
// New constructs the repo-bound task System for the named plugin and runs
|
|
82
|
+
// the adapter's immediate connectivity checks.
|
|
76
83
|
func New(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
77
84
|
f, err := lookup(name)
|
|
78
85
|
if err != nil {
|
|
79
86
|
return nil, err
|
|
80
87
|
}
|
|
81
88
|
spec.RootConfig = config.Merge(defaultConfig(f), spec.RootConfig)
|
|
89
|
+
if f.New == nil {
|
|
90
|
+
return nil, fmt.Errorf("task plugin %q has no constructor", name)
|
|
91
|
+
}
|
|
82
92
|
return f.New(ctx, spec)
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
// ValidateLocal reports whether a task plugin provides the local constructor
|
|
96
|
+
// required by normal startup. This is a machine-wide plugin configuration
|
|
97
|
+
// check, not a repo-specific health result.
|
|
98
|
+
func ValidateLocal(name string) error {
|
|
99
|
+
f, err := lookup(name)
|
|
100
|
+
if err != nil {
|
|
101
|
+
return err
|
|
102
|
+
}
|
|
103
|
+
if f.NewLocal == nil {
|
|
104
|
+
return fmt.Errorf("task plugin %q has no local constructor", name)
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// NewLocal constructs the repo-bound task state without remote probes. Every
|
|
110
|
+
// startup-capable task plugin must explicitly provide this seam; falling back
|
|
111
|
+
// to New would silently reintroduce startup connectivity checks.
|
|
112
|
+
func NewLocal(ctx context.Context, name string, spec RepoSpec) (System, error) {
|
|
113
|
+
f, err := lookup(name)
|
|
114
|
+
if err != nil {
|
|
115
|
+
return nil, err
|
|
116
|
+
}
|
|
117
|
+
spec.RootConfig = config.Merge(defaultConfig(f), spec.RootConfig)
|
|
118
|
+
if f.NewLocal == nil {
|
|
119
|
+
return nil, fmt.Errorf("task plugin %q has no local constructor", name)
|
|
120
|
+
}
|
|
121
|
+
return f.NewLocal(ctx, spec)
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
// Defaults returns a fresh copy of the selected task plugin's root config
|
|
86
125
|
// defaults for relay-flow init.
|
|
87
126
|
func Defaults(name string) (config.RawValues, error) {
|
|
@@ -267,31 +267,49 @@ func init() {
|
|
|
267
267
|
}
|
|
268
268
|
return strings.Join([]string{creds.Site, proj, comp}, "/"), nil
|
|
269
269
|
},
|
|
270
|
-
Auth:
|
|
271
|
-
New:
|
|
272
|
-
|
|
273
|
-
var cfg Config
|
|
274
|
-
if err := config.DecodeStrict(merged, &cfg); err != nil {
|
|
275
|
-
return nil, fmt.Errorf("jira repo %q config: %w", spec.Name, err)
|
|
276
|
-
}
|
|
277
|
-
creds, err := loadCredentialsDefault()
|
|
278
|
-
if err != nil {
|
|
279
|
-
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
280
|
-
}
|
|
281
|
-
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
282
|
-
if err != nil {
|
|
283
|
-
return nil, err
|
|
284
|
-
}
|
|
285
|
-
sys, err := newSystem(ctx, client, spec)
|
|
286
|
-
if err != nil {
|
|
287
|
-
return nil, err
|
|
288
|
-
}
|
|
289
|
-
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
290
|
-
return sys, nil
|
|
291
|
-
},
|
|
270
|
+
Auth: auth,
|
|
271
|
+
New: newSystemFromCredentials,
|
|
272
|
+
NewLocal: newSystemLocal,
|
|
292
273
|
})
|
|
293
274
|
}
|
|
294
275
|
|
|
276
|
+
func newSystemFromCredentials(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
277
|
+
creds, err := loadCredentialsDefault()
|
|
278
|
+
if err != nil {
|
|
279
|
+
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
280
|
+
}
|
|
281
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
282
|
+
if err != nil {
|
|
283
|
+
return nil, err
|
|
284
|
+
}
|
|
285
|
+
sys, err := newSystem(ctx, client, spec)
|
|
286
|
+
if err != nil {
|
|
287
|
+
return nil, err
|
|
288
|
+
}
|
|
289
|
+
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
290
|
+
return sys, nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// newSystemLocal builds a Jira client and typed adapter state without making
|
|
294
|
+
// assignee or status requests. Those remote checks run only while submitting
|
|
295
|
+
// a candidate workflow.
|
|
296
|
+
func newSystemLocal(ctx context.Context, spec task.RepoSpec) (task.System, error) {
|
|
297
|
+
creds, err := loadCredentialsDefault()
|
|
298
|
+
if err != nil {
|
|
299
|
+
return nil, fmt.Errorf("jira credentials: %w", err)
|
|
300
|
+
}
|
|
301
|
+
client, err := sharedClient(creds.Site, creds.Email, creds.Token)
|
|
302
|
+
if err != nil {
|
|
303
|
+
return nil, err
|
|
304
|
+
}
|
|
305
|
+
sys, err := newSystemWithValidation(ctx, client, spec, false)
|
|
306
|
+
if err != nil {
|
|
307
|
+
return nil, err
|
|
308
|
+
}
|
|
309
|
+
sys.currentUser = strings.TrimSpace(creds.Email)
|
|
310
|
+
return sys, nil
|
|
311
|
+
}
|
|
312
|
+
|
|
295
313
|
func sharedClient(site, email, token string) (*jirarest.HTTPClient, error) {
|
|
296
314
|
key := strings.TrimRight(site, "/") + "\x00" + email
|
|
297
315
|
clientsMu.Lock()
|
|
@@ -321,6 +339,10 @@ type system struct {
|
|
|
321
339
|
}
|
|
322
340
|
|
|
323
341
|
func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*system, error) {
|
|
342
|
+
return newSystemWithValidation(ctx, cli, spec, true)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func newSystemWithValidation(ctx context.Context, cli jirarest.Client, spec task.RepoSpec, remoteValidation bool) (*system, error) {
|
|
324
346
|
if spec.Name == "" {
|
|
325
347
|
return nil, fmt.Errorf("jira: repo name is required")
|
|
326
348
|
}
|
|
@@ -338,7 +360,7 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
|
|
|
338
360
|
if err := validateTemplates(cfg.Templates); err != nil {
|
|
339
361
|
return nil, fmt.Errorf("jira repo %q taskConfig.templates: %w", spec.Name, err)
|
|
340
362
|
}
|
|
341
|
-
if cfg.Assignee != "" {
|
|
363
|
+
if remoteValidation && cfg.Assignee != "" {
|
|
342
364
|
if err := cli.ValidateAssignee(ctx, cfg.Project, cfg.Assignee); err != nil {
|
|
343
365
|
return nil, fmt.Errorf("jira repo %q assignee %q: %w", spec.Name, cfg.Assignee, err)
|
|
344
366
|
}
|
|
@@ -349,11 +371,13 @@ func newSystem(ctx context.Context, cli jirarest.Client, spec task.RepoSpec) (*s
|
|
|
349
371
|
base: merged,
|
|
350
372
|
effective: cfg,
|
|
351
373
|
}
|
|
352
|
-
if
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
374
|
+
if remoteValidation {
|
|
375
|
+
if err := s.validateTransition(ctx, "repo config", cfg.Project, cfg.Transition); err != nil {
|
|
376
|
+
return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
|
|
377
|
+
}
|
|
378
|
+
if err := validateStatusDefaults(ctx, cli, "repo config", cfg.Project, cfg.StatusDefaults); err != nil {
|
|
379
|
+
return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
|
|
380
|
+
}
|
|
357
381
|
}
|
|
358
382
|
// project/component are required repo keys enforced at registration. Do not
|
|
359
383
|
// probe conventional lifecycle names here: repository registration stores
|
|
@@ -40,6 +40,11 @@ type Service struct {
|
|
|
40
40
|
// task system before storage. The composition root supplies this callback;
|
|
41
41
|
// keeping it here avoids widening RepoLookup beyond its documented query.
|
|
42
42
|
ValidateTaskConfig func(context.Context, *Workflow) error
|
|
43
|
+
// ValidateSubmission performs the workflow-scoped runner, task-system,
|
|
44
|
+
// harness, and other environment checks that must happen before the
|
|
45
|
+
// definition is persisted. Startup intentionally does not repeat these
|
|
46
|
+
// expensive checks for an accepted workflow.
|
|
47
|
+
ValidateSubmission func(context.Context, *Workflow) error
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
func NewService(store *Store, active ActiveRuns, repos RepoLookup) *Service {
|
|
@@ -89,12 +94,41 @@ func (s *Service) Submit(ctx context.Context, yamlBytes []byte) (*Workflow, erro
|
|
|
89
94
|
if active {
|
|
90
95
|
return nil, fmt.Errorf("workflow %q has active runs; replacement is rejected", wf.Name)
|
|
91
96
|
}
|
|
97
|
+
if s.ValidateSubmission != nil {
|
|
98
|
+
if err := s.ValidateSubmission(ctx, wf); err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
previousFiles, err := s.store.snapshot(wf.Name)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return nil, err
|
|
105
|
+
}
|
|
106
|
+
previous, hadPrevious := s.reg.Get(wf.Name)
|
|
107
|
+
wf.MarkHealthy()
|
|
92
108
|
if err := s.store.Put(wf.Name, yamlBytes); err != nil {
|
|
93
109
|
return nil, err
|
|
94
110
|
}
|
|
95
111
|
s.reg.Replace(wf)
|
|
96
112
|
if s.Rebind != nil {
|
|
97
113
|
if err := s.Rebind(); err != nil {
|
|
114
|
+
// A binding failure is part of submission failure: restore both the
|
|
115
|
+
// durable pair and the registry entry before returning.
|
|
116
|
+
restoreErr := s.store.restorePair(wf.Name, previousFiles)
|
|
117
|
+
if hadPrevious {
|
|
118
|
+
s.reg.Replace(previous)
|
|
119
|
+
} else {
|
|
120
|
+
s.reg.Remove(wf.Name)
|
|
121
|
+
}
|
|
122
|
+
if rebindErr := s.Rebind(); rebindErr != nil {
|
|
123
|
+
if restoreErr == nil {
|
|
124
|
+
restoreErr = rebindErr
|
|
125
|
+
} else {
|
|
126
|
+
restoreErr = fmt.Errorf("restore bindings: %v; restore files: %w", rebindErr, restoreErr)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if restoreErr != nil {
|
|
130
|
+
return nil, fmt.Errorf("rebind workflows for %q: %w (rollback failed: %v)", wf.Name, err, restoreErr)
|
|
131
|
+
}
|
|
98
132
|
return nil, fmt.Errorf("rebind workflows for %q: %w", wf.Name, err)
|
|
99
133
|
}
|
|
100
134
|
}
|
|
@@ -115,12 +149,31 @@ func (s *Service) Remove(ctx context.Context, name string) error {
|
|
|
115
149
|
if active {
|
|
116
150
|
return fmt.Errorf("workflow %q has active runs; removal is rejected", name)
|
|
117
151
|
}
|
|
152
|
+
previousFiles, err := s.store.snapshot(name)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return err
|
|
155
|
+
}
|
|
156
|
+
previous, hadPrevious := s.reg.Get(name)
|
|
118
157
|
if err := s.store.Remove(name); err != nil {
|
|
119
158
|
return err
|
|
120
159
|
}
|
|
121
160
|
s.reg.Remove(name)
|
|
122
161
|
if s.Rebind != nil {
|
|
123
162
|
if err := s.Rebind(); err != nil {
|
|
163
|
+
restoreErr := s.store.restorePair(name, previousFiles)
|
|
164
|
+
if hadPrevious {
|
|
165
|
+
s.reg.Replace(previous)
|
|
166
|
+
}
|
|
167
|
+
if rebindErr := s.Rebind(); rebindErr != nil {
|
|
168
|
+
if restoreErr == nil {
|
|
169
|
+
restoreErr = rebindErr
|
|
170
|
+
} else {
|
|
171
|
+
restoreErr = fmt.Errorf("restore bindings: %v; restore files: %w", rebindErr, restoreErr)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if restoreErr != nil {
|
|
175
|
+
return fmt.Errorf("rebind workflows after removing %q: %w (rollback failed: %v)", name, err, restoreErr)
|
|
176
|
+
}
|
|
124
177
|
return fmt.Errorf("rebind workflows after removing %q: %w", name, err)
|
|
125
178
|
}
|
|
126
179
|
}
|