relay-flow 0.3.7-alpha → 0.3.9-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 (34) hide show
  1. package/README.md +5 -5
  2. package/cmd/relay-flow/observability_test.go +28 -0
  3. package/cmd/relay-flow/render.go +20 -4
  4. package/cmd/relay-flow/scenario_test.go +15 -13
  5. package/cmd/relay-flow/serve.go +116 -103
  6. package/internal/execution/goworkflows/activities.go +17 -0
  7. package/internal/execution/goworkflows/cancellation_test.go +50 -0
  8. package/internal/execution/goworkflows/engine.go +278 -9
  9. package/internal/execution/goworkflows/fakes_test.go +10 -1
  10. package/internal/execution/goworkflows/interpreter.go +17 -1
  11. package/internal/execution/goworkflows/projection.go +8 -0
  12. package/internal/execution/goworkflows/recovery_test.go +482 -0
  13. package/internal/execution/projection/detail_test.go +38 -0
  14. package/internal/execution/projection/projection.go +80 -15
  15. package/internal/execution/temporal/activities.go +6 -0
  16. package/internal/execution/temporal/recovery.go +4 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/recover/recover.go +4 -0
  20. package/internal/repo/binding_test.go +92 -0
  21. package/internal/repo/poller.go +3 -0
  22. package/internal/repo/repo.go +82 -6
  23. package/internal/run/manager.go +41 -0
  24. package/internal/run/run_manager_test.go +56 -0
  25. package/internal/server/observability.go +1 -1
  26. package/internal/task/beads/beads.go +42 -4
  27. package/internal/task/beads/beads_test.go +15 -0
  28. package/internal/task/factory.go +41 -2
  29. package/internal/task/jira/jira.go +52 -28
  30. package/internal/workflow/service.go +53 -0
  31. package/internal/workflow/store.go +413 -19
  32. package/internal/workflow/store_test.go +235 -0
  33. package/internal/workflow/workflow.go +71 -0
  34. package/package.json +1 -1
@@ -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
  }