relay-flow 0.2.4-alpha → 0.2.6-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 (60) hide show
  1. package/README.md +29 -18
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/beads_composition_test.go +3 -3
  4. package/cmd/relay-flow/main.go +94 -13
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +98 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -0
  12. package/examples/config-reference.yaml +7 -3
  13. package/examples/minimal-beads-task-workflow.yaml +2 -1
  14. package/examples/workflow-reference.yaml +2 -1
  15. package/go.mod +37 -16
  16. package/go.sum +129 -61
  17. package/internal/config/machine.go +33 -1
  18. package/internal/config/machine_test.go +76 -0
  19. package/internal/execution/goworkflows/activities.go +19 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/engine_test.go +1 -1
  22. package/internal/execution/goworkflows/node_runtime_test.go +113 -4
  23. package/internal/execution/goworkflows/projection.go +47 -464
  24. package/internal/execution/projection/projection.go +867 -0
  25. package/internal/execution/projection/projection_test.go +347 -0
  26. package/internal/execution/temporal/activities.go +586 -0
  27. package/internal/execution/temporal/engine.go +384 -0
  28. package/internal/execution/temporal/engine_test.go +277 -0
  29. package/internal/execution/temporal/interpreter.go +736 -0
  30. package/internal/execution/temporal/operations.go +455 -0
  31. package/internal/execution/temporal/operations_test.go +101 -0
  32. package/internal/execution/temporal/recovery.go +194 -0
  33. package/internal/execution/temporal/recovery_runtime.go +41 -0
  34. package/internal/execution/temporal/recovery_test.go +102 -0
  35. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  36. package/internal/execution/temporal/spike_test.go +934 -0
  37. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  38. package/internal/harness/harness.go +5 -0
  39. package/internal/harness/opencode/opencode.go +14 -3
  40. package/internal/harness/opencode/opencode_test.go +1 -1
  41. package/internal/harness/opencode/repo_setup.go +1 -1
  42. package/internal/harness/opencode/task_env_test.go +57 -0
  43. package/internal/harness/pi/pi.go +58 -47
  44. package/internal/harness/pi/pi_test.go +26 -10
  45. package/internal/harness/pi/prompt_test.go +30 -1
  46. package/internal/harness/pi/task_env_test.go +51 -0
  47. package/internal/harness/pi/validation_test.go +27 -51
  48. package/internal/repo/service.go +18 -8
  49. package/internal/runner/herdr/herdr.go +14 -0
  50. package/internal/runner/herdr/herdr_test.go +20 -0
  51. package/internal/runner/orca/orca.go +33 -0
  52. package/internal/runner/orca/orca_test.go +33 -4
  53. package/internal/runner/runner.go +8 -0
  54. package/internal/task/beads/agent_env_test.go +52 -0
  55. package/internal/task/beads/beads.go +87 -7
  56. package/internal/task/beads/beads_test.go +78 -9
  57. package/internal/task/beads/repo_composition_test.go +47 -3
  58. package/internal/task/factory.go +31 -2
  59. package/internal/task/task.go +10 -0
  60. package/package.json +1 -1
@@ -21,6 +21,8 @@ import (
21
21
 
22
22
  "github.com/rajpopat27/relay-flow/internal/config"
23
23
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
24
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
25
+ temporalexec "github.com/rajpopat27/relay-flow/internal/execution/temporal"
24
26
  "github.com/rajpopat27/relay-flow/internal/harness"
25
27
  "github.com/rajpopat27/relay-flow/internal/paths"
26
28
  recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
@@ -179,13 +181,35 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
179
181
  // provably empty database, and Engine.Start has no prior history
180
182
  // to resume.
181
183
  if recover {
182
- stamp := time.Now().UTC().Format("20060102T150405Z")
183
- for _, suffix := range []string{"", "-wal", "-shm"} {
184
+ // Validate the existing installation identity before moving any state
185
+ // aside. A changed executor/address/namespace must fail closed; only a
186
+ // genuinely missing database may be initialized by recovery.
187
+ if _, statErr := os.Stat(p.Database); statErr == nil {
188
+ expected := projection.ExecutorIdentity{ExecutorPlugin: cfg.ExecutorPlugin}
189
+ if cfg.ExecutorPlugin == executorTemporal {
190
+ expected.TemporalAddress = cfg.TemporalAddress
191
+ expected.TemporalNamespace = cfg.TemporalNamespace
192
+ }
193
+ if err := verifyExecutorIdentity(p.Database, expected); err != nil && !errors.Is(err, ErrProjectionUnusable) {
194
+ return fmt.Errorf("recover: verify executor identity before backup: %w", err)
195
+ }
196
+ } else if !os.IsNotExist(statErr) {
197
+ return fmt.Errorf("recover: stat database %s: %w", p.Database, statErr)
198
+ }
199
+ stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
200
+ suffixes := []string{"", "-wal", "-shm"}
201
+ backupStem, err := recoveryBackupStem(p.Database, stamp, suffixes)
202
+ if err != nil {
203
+ return err
204
+ }
205
+ for _, suffix := range suffixes {
184
206
  src := p.Database + suffix
185
207
  if _, err := os.Stat(src); os.IsNotExist(err) {
186
208
  continue
209
+ } else if err != nil {
210
+ return fmt.Errorf("inspect stale database %s: %w", src, err)
187
211
  }
188
- dst := fmt.Sprintf("%s.recover-%s.bak%s", p.Database, stamp, suffix)
212
+ dst := backupStem + ".bak" + suffix
189
213
  if err := os.Rename(src, dst); err != nil {
190
214
  return fmt.Errorf("preserve stale database %s as %s: %w", src, dst, err)
191
215
  }
@@ -201,21 +225,32 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
201
225
  }
202
226
  }
203
227
 
204
- // Open the go-workflows SQLite engine (migrates relay_runs; does not
205
- // yet start workers).
206
- engine, err := goworkflows.New(p.Database, goworkflows.Dependencies{
207
- Repos: repoReg,
208
- Runner: rnr,
209
- Harness: hrn,
210
- TaskSystem: cfg.TaskPlugin,
211
- RetentionDays: cfg.CompletedRunRetentionDays,
212
- Runtime: &runsvc.RuntimePolicy{
213
- KeepTerminalsAlive: cfg.KeepTerminalsAlive,
214
- KeepSessionsAlive: cfg.KeepSessionsAlive,
215
- },
216
- })
228
+ // Open exactly the configured durable executor. The machine config and
229
+ // projection identity have already selected/fenced this backend; there is
230
+ // deliberately no probe-and-fallback path.
231
+ var engine durableEngine
232
+ runtimePolicy := &runsvc.RuntimePolicy{
233
+ KeepTerminalsAlive: cfg.KeepTerminalsAlive,
234
+ KeepSessionsAlive: cfg.KeepSessionsAlive,
235
+ }
236
+ switch cfg.ExecutorPlugin {
237
+ case "goworkflows":
238
+ engine, err = goworkflows.New(p.Database, goworkflows.Dependencies{
239
+ Repos: repoReg, Runner: rnr, Harness: hrn, TaskSystem: cfg.TaskPlugin,
240
+ RetentionDays: cfg.CompletedRunRetentionDays, Runtime: runtimePolicy,
241
+ })
242
+ case "temporal":
243
+ engine, err = temporalexec.New(p.Database, temporalexec.Dependencies{
244
+ Repos: repoReg, Runner: rnr, Harness: hrn, TaskSystem: cfg.TaskPlugin,
245
+ RetentionDays: cfg.CompletedRunRetentionDays, Runtime: runtimePolicy,
246
+ TemporalAddress: cfg.TemporalAddress, TemporalNamespace: cfg.TemporalNamespace,
247
+ Recover: recover,
248
+ })
249
+ default:
250
+ err = fmt.Errorf("unknown executor plugin %q (want goworkflows or temporal)", cfg.ExecutorPlugin)
251
+ }
217
252
  if err != nil {
218
- return fmt.Errorf("open engine: %w", err)
253
+ return fmt.Errorf("open %s engine: %w", cfg.ExecutorPlugin, err)
219
254
  }
220
255
 
221
256
  // 5.2 fail-fast preflight: before workers/pollers start, validate
@@ -278,7 +313,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
278
313
  // recovered runs exist before any poll cycle observes them. Mailbox
279
314
  // specs come from the engine so the recover path builds the same
280
315
  // description content as normal run execution.
281
- if recover {
316
+ if recover && cfg.ExecutorPlugin == "goworkflows" {
282
317
  specsFor := func(sys task.System, work runsvc.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
283
318
  return goworkflows.RenderMailboxSpecs(sys, work, wf)
284
319
  }
@@ -362,6 +397,18 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
362
397
  // single cleanup path.
363
398
  stopCtx, stopServe := context.WithCancel(context.Background())
364
399
  defer stopServe()
400
+ if fatalEngine, ok := engine.(interface{ FatalErrors() <-chan error }); ok {
401
+ go func() {
402
+ select {
403
+ case fatalErr := <-fatalEngine.FatalErrors():
404
+ if fatalErr != nil {
405
+ slog.Error("durable executor worker failed", "error", fatalErr)
406
+ }
407
+ stopServe()
408
+ case <-stopCtx.Done():
409
+ }
410
+ }()
411
+ }
365
412
 
366
413
  deps := &serveDeps{
367
414
  wf: wfSvc,
@@ -402,6 +449,29 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
402
449
  return serveResult
403
450
  }
404
451
 
452
+ func recoveryBackupStem(database, stamp string, suffixes []string) (string, error) {
453
+ for attempt := 0; attempt < 1000; attempt++ {
454
+ candidateStamp := stamp
455
+ if attempt > 0 {
456
+ candidateStamp = fmt.Sprintf("%s-%d", stamp, attempt)
457
+ }
458
+ stem := fmt.Sprintf("%s.recover-%s", database, candidateStamp)
459
+ collision := false
460
+ for _, suffix := range suffixes {
461
+ if _, err := os.Stat(stem + ".bak" + suffix); err == nil {
462
+ collision = true
463
+ break
464
+ } else if !os.IsNotExist(err) {
465
+ return "", fmt.Errorf("inspect recovery backup %s: %w", stem+".bak"+suffix, err)
466
+ }
467
+ }
468
+ if !collision {
469
+ return stem, nil
470
+ }
471
+ }
472
+ return "", fmt.Errorf("unable to allocate unique recovery backup for %s", database)
473
+ }
474
+
405
475
  func workflowConfigValidator(repoReg *repo.Registry) func(context.Context, *workflow.Workflow) error {
406
476
  return func(ctx context.Context, wf *workflow.Workflow) error {
407
477
  nodeCfgs := map[string]config.RawValues{}
@@ -431,10 +501,19 @@ func (r repoExists) Exists(name string) bool {
431
501
 
432
502
  // serveDeps adapts composition-root services to server.Deps. Thin forwarder;
433
503
  // no logic. Signatures match docs/structs-methods-interfaces.md Client.
504
+ type durableEngine interface {
505
+ runsvc.Executor
506
+ runsvc.RunQueries
507
+ Start(context.Context) error
508
+ Shutdown(context.Context) error
509
+ HasProcessedReport(context.Context, runsvc.ID, string) (bool, error)
510
+ RegisterNodeSession(context.Context, runsvc.NodeRuntimeRegistration) (runsvc.NodeRuntimeRegistrationAck, error)
511
+ }
512
+
434
513
  type serveDeps struct {
435
514
  wf *workflow.Service
436
515
  repos *repo.Service
437
- engine *goworkflows.Engine
516
+ engine durableEngine
438
517
  runManager *runsvc.RunManager
439
518
  onReposChanged func()
440
519
  shutdown func(context.Context) error
@@ -0,0 +1,100 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/config"
12
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
13
+ "github.com/rajpopat27/relay-flow/internal/paths"
14
+ )
15
+
16
+ func TestCorruptProjectionIsClassifiedUnusableForRecovery(t *testing.T) {
17
+ path := filepath.Join(t.TempDir(), "state.db")
18
+ if err := os.WriteFile(path, []byte("not sqlite"), 0o600); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ err := verifyExecutorIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "corrupt"})
22
+ if !errors.Is(err, ErrProjectionUnusable) {
23
+ t.Fatalf("corrupt projection error = %v, want ErrProjectionUnusable", err)
24
+ }
25
+ }
26
+
27
+ func TestTemporalRecoveryRejectsIdentityMismatchBeforeBackup(t *testing.T) {
28
+ log := newScenarioLog()
29
+ setScenarioFactoryAdapters(newScenarioTaskSystem(log), newScenarioRunner(log), newScenarioHarness(log))
30
+ root := filepath.Join(t.TempDir(), ".relay-flow")
31
+ p := pathsForRoot(root)
32
+ if err := paths.Ensure(p); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ if err := config.SaveMachine(p.Config, &config.Machine{
36
+ TaskPlugin: scenarioTaskPlugin, RunnerPlugin: scenarioRunnerPlugin, HarnessPlugin: scenarioHarnessPlugin,
37
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "identity-recovery",
38
+ }); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ if err := projection.InitDatabaseWithIdentity(p.Database, projection.ExecutorIdentity{
42
+ ExecutorPlugin: "temporal", TemporalAddress: "other-host:7233", TemporalNamespace: "identity-recovery",
43
+ }); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if err := serveRoot(context.Background(), p, true); !errors.Is(err, projection.ErrIdentityMismatch) {
47
+ t.Fatalf("recovery identity mismatch error = %v", err)
48
+ }
49
+ if _, err := os.Stat(p.Database); err != nil {
50
+ t.Fatalf("identity mismatch moved database before failing: %v", err)
51
+ }
52
+ backups, err := filepath.Glob(p.Database + ".recover-*.bak")
53
+ if err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ if len(backups) != 0 {
57
+ t.Fatalf("identity mismatch created recovery backups: %v", backups)
58
+ }
59
+ }
60
+
61
+ func TestTemporalRecoveryRejectsMarkerlessExistingDatabase(t *testing.T) {
62
+ log := newScenarioLog()
63
+ setScenarioFactoryAdapters(newScenarioTaskSystem(log), newScenarioRunner(log), newScenarioHarness(log))
64
+ root := filepath.Join(t.TempDir(), ".relay-flow")
65
+ p := pathsForRoot(root)
66
+ if err := paths.Ensure(p); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ if err := config.SaveMachine(p.Config, &config.Machine{
70
+ TaskPlugin: scenarioTaskPlugin, RunnerPlugin: scenarioRunnerPlugin, HarnessPlugin: scenarioHarnessPlugin,
71
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "markerless-recovery",
72
+ }); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ if err := projection.InitDatabase(p.Database); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ if err := serveRoot(context.Background(), p, true); !errors.Is(err, projection.ErrIdentityMissing) {
79
+ t.Fatalf("markerless Temporal recovery error = %v", err)
80
+ }
81
+ if _, err := os.Stat(p.Database); err != nil {
82
+ t.Fatalf("markerless database moved before failing: %v", err)
83
+ }
84
+ }
85
+
86
+ func TestRecoveryBackupPathAvoidsSameTimestampCollision(t *testing.T) {
87
+ database := filepath.Join(t.TempDir(), "state.db")
88
+ stamp := "20260905T010203.000000000Z"
89
+ first := database + ".recover-" + stamp + ".bak"
90
+ if err := os.WriteFile(first, []byte("existing"), 0o600); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ stem, err := recoveryBackupStem(database, stamp, []string{"", "-wal", "-shm"})
94
+ if err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ if !strings.Contains(stem, ".recover-"+stamp+"-1") {
98
+ t.Fatalf("collision-safe backup stem = %q, want alternate", stem)
99
+ }
100
+ }
@@ -0,0 +1,170 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/charmbracelet/huh"
12
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
13
+ "go.temporal.io/api/serviceerror"
14
+ workflowservice "go.temporal.io/api/workflowservice/v1"
15
+ "go.temporal.io/sdk/client"
16
+ "google.golang.org/protobuf/types/known/durationpb"
17
+ )
18
+
19
+ var ErrProjectionUnusable = errors.New("relay projection is unusable")
20
+
21
+ const (
22
+ executorGoworkflows = "goworkflows"
23
+ executorTemporal = "temporal"
24
+ defaultTemporalAddr = "localhost:7233"
25
+ minimumTemporalRetention = 30 * 24 * time.Hour
26
+ )
27
+
28
+ // executorSelectField is the exact interactive executor selection required by
29
+ // the Temporal MVP. The first option deliberately remains the embedded default.
30
+ func executorSelectField(value *string) (huh.Field, error) {
31
+ if value == nil {
32
+ return nil, fmt.Errorf("executor selection requires a destination")
33
+ }
34
+ return huh.NewSelect[string]().
35
+ Title("Select executor").
36
+ Options(huh.NewOptions(executorGoworkflows, executorTemporal)...).
37
+ Value(value), nil
38
+ }
39
+
40
+ func temporalAddressField(value *string) (huh.Field, error) {
41
+ if value == nil {
42
+ return nil, fmt.Errorf("Temporal address requires a destination")
43
+ }
44
+ if *value == "" {
45
+ *value = defaultTemporalAddr
46
+ }
47
+ return huh.NewInput().
48
+ Title("Temporal server address").
49
+ Value(value), nil
50
+ }
51
+
52
+ func temporalNamespaceField(value *string) (huh.Field, error) {
53
+ if value == nil {
54
+ return nil, fmt.Errorf("Temporal namespace requires a destination")
55
+ }
56
+ return huh.NewInput().
57
+ Title("Temporal namespace/team name").
58
+ Value(value), nil
59
+ }
60
+
61
+ func promptTemporalSettings(address, namespace string) (string, string, error) {
62
+ groups := make([]*huh.Group, 0, 2)
63
+ if address == "" {
64
+ field, err := temporalAddressField(&address)
65
+ if err != nil {
66
+ return "", "", err
67
+ }
68
+ groups = append(groups, huh.NewGroup(field))
69
+ }
70
+ if namespace == "" {
71
+ field, err := temporalNamespaceField(&namespace)
72
+ if err != nil {
73
+ return "", "", err
74
+ }
75
+ groups = append(groups, huh.NewGroup(field))
76
+ }
77
+ if len(groups) > 0 {
78
+ if err := huh.NewForm(groups...).Run(); err != nil {
79
+ return "", "", err
80
+ }
81
+ }
82
+ return strings.TrimSpace(address), strings.TrimSpace(namespace), nil
83
+ }
84
+
85
+ func requiredTemporalRetention(days int) time.Duration {
86
+ retention := minimumTemporalRetention
87
+ if days > 0 && time.Duration(days)*24*time.Hour > retention {
88
+ retention = time.Duration(days) * 24 * time.Hour
89
+ }
90
+ return retention
91
+ }
92
+
93
+ // ensureTemporalNamespace creates a missing namespace or verifies an existing
94
+ // one through the public SDK. Existing retention is never lowered or silently
95
+ // changed by relay-flow.
96
+ func verifyExecutorIdentity(path string, expected projection.ExecutorIdentity) error {
97
+ db, err := sql.Open("sqlite", path)
98
+ if err != nil {
99
+ return fmt.Errorf("open executor identity %s: %w", path, err)
100
+ }
101
+ defer db.Close()
102
+ db.SetMaxOpenConns(1)
103
+ if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
104
+ return fmt.Errorf("%w: inspect executor identity %s: %v", ErrProjectionUnusable, path, err)
105
+ }
106
+ // Legacy databases predate the identity table. Migrate only the shared
107
+ // relay projection before verification so an embedded legacy home can be
108
+ // adopted without touching its engine history.
109
+ proj := &projection.RunProjection{DB: db}
110
+ if err := proj.Migrate(); err != nil {
111
+ return fmt.Errorf("%w: migrate relay projection %s: %v", ErrProjectionUnusable, path, err)
112
+ }
113
+ if err := proj.VerifyIdentity(context.Background(), expected); err != nil {
114
+ if errors.Is(err, projection.ErrIdentityMismatch) || errors.Is(err, projection.ErrIdentityMissing) {
115
+ return err
116
+ }
117
+ return fmt.Errorf("%w: verify executor identity %s: %v", ErrProjectionUnusable, path, err)
118
+ }
119
+ return nil
120
+ }
121
+
122
+ func ensureTemporalNamespace(ctx context.Context, address, namespace string, retentionDays int) error {
123
+ address = strings.TrimSpace(address)
124
+ namespace = strings.TrimSpace(namespace)
125
+ if address == "" {
126
+ address = defaultTemporalAddr
127
+ }
128
+ if namespace == "" {
129
+ return fmt.Errorf("Temporal namespace is required")
130
+ }
131
+ if namespace == client.DefaultNamespace {
132
+ return fmt.Errorf("Temporal namespace must be a dedicated named namespace, not %q", client.DefaultNamespace)
133
+ }
134
+ manager, err := client.NewNamespaceClient(client.Options{HostPort: address})
135
+ if err != nil {
136
+ return fmt.Errorf("connect to Temporal namespace manager at %s: %w", address, err)
137
+ }
138
+ defer manager.Close()
139
+
140
+ required := requiredTemporalRetention(retentionDays)
141
+ description, err := manager.Describe(ctx, namespace)
142
+ if err != nil {
143
+ var notFound *serviceerror.NamespaceNotFound
144
+ if !errors.As(err, &notFound) {
145
+ return fmt.Errorf("describe Temporal namespace %q: %w", namespace, err)
146
+ }
147
+ if err := manager.Register(ctx, &workflowservice.RegisterNamespaceRequest{
148
+ Namespace: namespace,
149
+ Description: "relay-flow durable executor",
150
+ WorkflowExecutionRetentionPeriod: durationpb.New(required),
151
+ }); err != nil {
152
+ var alreadyExists *serviceerror.NamespaceAlreadyExists
153
+ if !errors.As(err, &alreadyExists) {
154
+ return fmt.Errorf("register Temporal namespace %q: %w", namespace, err)
155
+ }
156
+ }
157
+ description, err = manager.Describe(ctx, namespace)
158
+ if err != nil {
159
+ return fmt.Errorf("verify registered Temporal namespace %q: %w", namespace, err)
160
+ }
161
+ }
162
+ if description.Config == nil || description.Config.WorkflowExecutionRetentionTtl == nil {
163
+ return fmt.Errorf("Temporal namespace %q has no workflow retention configuration", namespace)
164
+ }
165
+ actual := description.Config.WorkflowExecutionRetentionTtl.AsDuration()
166
+ if actual < required {
167
+ return fmt.Errorf("Temporal namespace %q retention is %s, need at least %s", namespace, actual, required)
168
+ }
169
+ return nil
170
+ }
@@ -0,0 +1,217 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "database/sql"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/config"
15
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
16
+ "github.com/rajpopat27/relay-flow/internal/paths"
17
+ _ "modernc.org/sqlite"
18
+ )
19
+
20
+ func TestInitExecutorSelectionFieldUsesExactTitleAndEmbeddedDefault(t *testing.T) {
21
+ var selected string
22
+ field, err := executorSelectField(&selected)
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if field == nil {
27
+ t.Fatal("executor selection field is nil")
28
+ }
29
+ var out bytes.Buffer
30
+ if err := field.RunAccessible(&out, strings.NewReader("1\n")); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ if selected != "goworkflows" {
34
+ t.Fatalf("default executor selection = %q, want goworkflows", selected)
35
+ }
36
+ if !strings.Contains(out.String(), "Select executor") {
37
+ t.Fatalf("executor selection output %q missing exact title", out.String())
38
+ }
39
+ }
40
+
41
+ func TestTemporalSettingsFieldsUseApprovedTitlesAndAddressDefault(t *testing.T) {
42
+ address := ""
43
+ field, err := temporalAddressField(&address)
44
+ if err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ var out bytes.Buffer
48
+ if err := field.RunAccessible(&out, strings.NewReader("\n")); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ if address != defaultTemporalAddr || !strings.Contains(out.String(), "Temporal server address") {
52
+ t.Fatalf("address field = %q, output %q", address, out.String())
53
+ }
54
+ namespace := ""
55
+ field, err = temporalNamespaceField(&namespace)
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ out.Reset()
60
+ if err := field.RunAccessible(&out, strings.NewReader("relay-flow-team\n")); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ if namespace != "relay-flow-team" || !strings.Contains(out.String(), "Temporal namespace/team name") {
64
+ t.Fatalf("namespace field = %q, output %q", namespace, out.String())
65
+ }
66
+ }
67
+
68
+ func TestInitRejectsTemporalOnlyFlagsForEmbeddedExecutor(t *testing.T) {
69
+ home := t.TempDir()
70
+ code := cli(t, home, "", "init",
71
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode",
72
+ "--executor-plugin", "goworkflows", "--temporal-address", "localhost:7233",
73
+ )
74
+ if code == 0 {
75
+ t.Fatal("embedded init accepted Temporal-only flags")
76
+ }
77
+ if _, err := os.Stat(filepath.Join(home, ".relay-flow", "config.yaml")); !os.IsNotExist(err) {
78
+ t.Fatalf("rejected embedded init wrote config: %v", err)
79
+ }
80
+ }
81
+
82
+ func TestInitTemporalFailureDoesNotWritePartialConfiguration(t *testing.T) {
83
+ home := t.TempDir()
84
+ code := cli(t, home, "", "init",
85
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode",
86
+ "--executor-plugin", "temporal", "--temporal-address", "127.0.0.1:1", "--temporal-namespace", "relay-flow-failure",
87
+ )
88
+ if code == 0 {
89
+ t.Fatal("Temporal init unexpectedly succeeded against unavailable server")
90
+ }
91
+ root := filepath.Join(home, ".relay-flow")
92
+ for _, name := range []string{"config.yaml", "state.db"} {
93
+ if _, err := os.Stat(filepath.Join(root, name)); !os.IsNotExist(err) {
94
+ t.Fatalf("failed Temporal init wrote %s: %v", name, err)
95
+ }
96
+ }
97
+ }
98
+
99
+ func TestTemporalInitLiveCreatesNamedNamespaceAndIdentity(t *testing.T) {
100
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
101
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal init against the local server")
102
+ }
103
+ home := t.TempDir()
104
+ namespace := fmt.Sprintf("relay-flow-init-%d", time.Now().UnixNano())
105
+ code := cli(t, home, "", "init",
106
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode",
107
+ "--executor-plugin", "temporal", "--temporal-address", "localhost:7233", "--temporal-namespace", namespace,
108
+ )
109
+ if code != 0 {
110
+ t.Fatalf("Temporal init exit = %d, want 0", code)
111
+ }
112
+ root := filepath.Join(home, ".relay-flow")
113
+ cfg, err := config.LoadMachine(filepath.Join(root, "config.yaml"))
114
+ if err != nil {
115
+ t.Fatal(err)
116
+ }
117
+ if cfg.ExecutorPlugin != "temporal" || cfg.TemporalAddress != "localhost:7233" || cfg.TemporalNamespace != namespace {
118
+ t.Fatalf("Temporal config = %+v", cfg)
119
+ }
120
+ db, err := sql.Open("sqlite", filepath.Join(root, "state.db"))
121
+ if err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ defer db.Close()
125
+ var plugin, address, persistedNamespace string
126
+ if err := db.QueryRowContext(context.Background(), `SELECT executor_plugin, temporal_address, temporal_namespace FROM relay_executor_identity WHERE singleton = 1`).Scan(&plugin, &address, &persistedNamespace); err != nil {
127
+ t.Fatalf("read initialized executor identity: %v", err)
128
+ }
129
+ if plugin != "temporal" || address != "localhost:7233" || persistedNamespace != namespace {
130
+ t.Fatalf("persisted Temporal identity = %q/%q/%q", plugin, address, persistedNamespace)
131
+ }
132
+ }
133
+
134
+ func TestInitForceAdoptsLegacyMarkerlessGoworkflowsDatabase(t *testing.T) {
135
+ home := t.TempDir()
136
+ root := filepath.Join(home, ".relay-flow")
137
+ p := pathsForRoot(root)
138
+ if err := paths.Ensure(p); err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ if err := config.SaveMachine(p.Config, &config.Machine{TaskPlugin: "jira", RunnerPlugin: "orca", HarnessPlugin: "opencode"}); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ if err := projection.InitDatabase(p.Database); err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ if code := cli(t, home, "", "init", "--force", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode", "--executor-plugin", "goworkflows"); code != 0 {
148
+ t.Fatalf("legacy markerless init --force exit = %d", code)
149
+ }
150
+ db, err := sql.Open("sqlite", p.Database)
151
+ if err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ defer db.Close()
155
+ var plugin string
156
+ if err := db.QueryRow(`SELECT executor_plugin FROM relay_executor_identity WHERE singleton = 1`).Scan(&plugin); err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if plugin != "goworkflows" {
160
+ t.Fatalf("adopted legacy executor identity = %q", plugin)
161
+ }
162
+ }
163
+
164
+ func TestInitForceRejectsExecutorChange(t *testing.T) {
165
+ home := t.TempDir()
166
+ initHome(t, home)
167
+ before := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml"))
168
+ if code := cli(t, home, "", "init", "--force",
169
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode",
170
+ "--executor-plugin", "temporal", "--temporal-namespace", "relay-flow-change"); code == 0 {
171
+ t.Fatal("init --force accepted an executor change")
172
+ }
173
+ if got := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml")); got != before {
174
+ t.Fatal("rejected executor change modified config")
175
+ }
176
+ }
177
+
178
+ func TestTemporalInitForceRejectsDurableIdentityChangeLive(t *testing.T) {
179
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
180
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run Temporal init against the local server")
181
+ }
182
+ home := t.TempDir()
183
+ namespace := fmt.Sprintf("relay-flow-force-%d", time.Now().UnixNano())
184
+ args := []string{"init", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode", "--executor-plugin", "temporal", "--temporal-address", "localhost:7233", "--temporal-namespace", namespace}
185
+ if code := cli(t, home, "", args...); code != 0 {
186
+ t.Fatalf("initial Temporal init exit = %d", code)
187
+ }
188
+ before := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml"))
189
+ for name, changed := range map[string][]string{
190
+ "address": {"init", "--force", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode", "--executor-plugin", "temporal", "--temporal-address", "localhost:7443", "--temporal-namespace", namespace},
191
+ "namespace": {"init", "--force", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode", "--executor-plugin", "temporal", "--temporal-address", "localhost:7233", "--temporal-namespace", namespace + "-changed"},
192
+ "executor": {"init", "--force", "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode", "--executor-plugin", "goworkflows"},
193
+ } {
194
+ t.Run(name, func(t *testing.T) {
195
+ if code := cli(t, home, "", changed...); code == 0 {
196
+ t.Fatalf("init --force accepted changed Temporal %s", name)
197
+ }
198
+ if got := readFile(t, filepath.Join(home, ".relay-flow", "config.yaml")); got != before {
199
+ t.Fatalf("rejected init --force %s change modified config", name)
200
+ }
201
+ })
202
+ }
203
+ }
204
+
205
+ func TestInitTemporalNonInteractiveRequiresExplicitNamespace(t *testing.T) {
206
+ home := t.TempDir()
207
+ code := cli(t, home, "", "init",
208
+ "--task-plugin", "jira", "--runner-plugin", "orca", "--harness-plugin", "opencode",
209
+ "--executor-plugin", "temporal", "--temporal-address", "localhost:7233",
210
+ )
211
+ if code == 0 {
212
+ t.Fatal("non-interactive Temporal init succeeded without namespace")
213
+ }
214
+ if _, err := os.Stat(filepath.Join(home, ".relay-flow", "config.yaml")); !os.IsNotExist(err) {
215
+ t.Fatalf("missing-namespace init wrote config: %v", err)
216
+ }
217
+ }