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.
@@ -31,8 +31,12 @@ type Repo struct {
31
31
  Path string
32
32
  TaskConfig config.RawValues
33
33
  TaskSystem task.System
34
- Workflows []WorkflowBinding
35
- bindingsMu sync.RWMutex
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
- return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
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
- return fmt.Errorf("workflow %q repo %q: compile filter: %w", wf.Name, repoName, err)
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
  }
@@ -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 canonicalBeadsDir(repo.BeadsDir)
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, err := beadsTaskScopeKey(spec.RootConfig, spec.RepoConfig)
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 err := cli.Probe(ctx); err != nil {
250
- return nil, fmt.Errorf("beads repo %q probe: %w", spec.Name, err)
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())
@@ -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 func(context.Context, RepoSpec) (System, error)
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: auth,
271
- New: func(ctx context.Context, spec task.RepoSpec) (task.System, error) {
272
- merged := config.Merge(spec.RootConfig, spec.RepoConfig)
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 err := s.validateTransition(ctx, "repo config", cfg.Project, cfg.Transition); err != nil {
353
- return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
354
- }
355
- if err := validateStatusDefaults(ctx, cli, "repo config", cfg.Project, cfg.StatusDefaults); err != nil {
356
- return nil, fmt.Errorf("jira repo %q: %w", spec.Name, err)
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
  }