relay-flow 0.2.3-alpha → 0.2.5-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 (85) hide show
  1. package/README.md +121 -10
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +96 -13
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +100 -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 -3
  12. package/examples/config-reference.yaml +144 -0
  13. package/examples/minimal-beads-task-workflow.yaml +35 -0
  14. package/examples/minimal-jira-task-workflow.yaml +68 -0
  15. package/examples/workflow-reference.yaml +112 -0
  16. package/go.mod +37 -16
  17. package/go.sum +129 -61
  18. package/internal/config/machine.go +33 -1
  19. package/internal/config/machine_test.go +76 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/projection.go +47 -464
  22. package/internal/execution/projection/projection.go +867 -0
  23. package/internal/execution/projection/projection_test.go +347 -0
  24. package/internal/execution/temporal/activities.go +567 -0
  25. package/internal/execution/temporal/engine.go +384 -0
  26. package/internal/execution/temporal/engine_test.go +277 -0
  27. package/internal/execution/temporal/interpreter.go +736 -0
  28. package/internal/execution/temporal/operations.go +455 -0
  29. package/internal/execution/temporal/operations_test.go +101 -0
  30. package/internal/execution/temporal/recovery.go +194 -0
  31. package/internal/execution/temporal/recovery_runtime.go +41 -0
  32. package/internal/execution/temporal/recovery_test.go +102 -0
  33. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  34. package/internal/execution/temporal/spike_test.go +934 -0
  35. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  36. package/internal/harness/opencode/opencode.go +3 -1
  37. package/internal/harness/opencode/opencode_test.go +1 -1
  38. package/internal/harness/opencode/repo_setup.go +1 -1
  39. package/internal/harness/pi/config_test.go +46 -0
  40. package/internal/harness/pi/lifecycle_test.go +69 -0
  41. package/internal/harness/pi/pi.go +264 -0
  42. package/internal/harness/pi/pi_test.go +338 -0
  43. package/internal/harness/pi/prompt_test.go +150 -0
  44. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  45. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  46. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  47. package/internal/harness/pi/validation_test.go +144 -0
  48. package/internal/runner/herdr/baseref.go +60 -0
  49. package/internal/runner/herdr/herdr.go +592 -0
  50. package/internal/runner/herdr/herdr_test.go +708 -0
  51. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  52. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  53. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  54. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  55. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  56. package/internal/runner/herdr/herdrcli/response.go +116 -0
  57. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  58. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  59. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  60. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  61. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  62. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  63. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  64. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  65. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  66. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  67. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  68. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  69. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  70. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  71. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  72. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  73. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  74. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  75. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  76. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  77. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  78. package/internal/runner/orca/orca.go +33 -0
  79. package/internal/runner/orca/orca_test.go +33 -4
  80. package/internal/runner/runner.go +8 -0
  81. package/internal/task/beads/beads.go +0 -16
  82. package/internal/task/beads/config_compatibility_test.go +7 -8
  83. package/internal/task/jira/filters_test.go +32 -6
  84. package/internal/task/jira/jira.go +27 -17
  85. 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"
@@ -34,6 +36,8 @@ import (
34
36
 
35
37
  // Adapter registrations (factories are registered by name via init).
36
38
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
39
+ _ "github.com/rajpopat27/relay-flow/internal/harness/pi"
40
+ _ "github.com/rajpopat27/relay-flow/internal/runner/herdr"
37
41
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
38
42
  _ "github.com/rajpopat27/relay-flow/internal/task/beads"
39
43
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
@@ -177,13 +181,35 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
177
181
  // provably empty database, and Engine.Start has no prior history
178
182
  // to resume.
179
183
  if recover {
180
- stamp := time.Now().UTC().Format("20060102T150405Z")
181
- 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 {
182
206
  src := p.Database + suffix
183
207
  if _, err := os.Stat(src); os.IsNotExist(err) {
184
208
  continue
209
+ } else if err != nil {
210
+ return fmt.Errorf("inspect stale database %s: %w", src, err)
185
211
  }
186
- dst := fmt.Sprintf("%s.recover-%s.bak%s", p.Database, stamp, suffix)
212
+ dst := backupStem + ".bak" + suffix
187
213
  if err := os.Rename(src, dst); err != nil {
188
214
  return fmt.Errorf("preserve stale database %s as %s: %w", src, dst, err)
189
215
  }
@@ -199,21 +225,32 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
199
225
  }
200
226
  }
201
227
 
202
- // Open the go-workflows SQLite engine (migrates relay_runs; does not
203
- // yet start workers).
204
- engine, err := goworkflows.New(p.Database, goworkflows.Dependencies{
205
- Repos: repoReg,
206
- Runner: rnr,
207
- Harness: hrn,
208
- TaskSystem: cfg.TaskPlugin,
209
- RetentionDays: cfg.CompletedRunRetentionDays,
210
- Runtime: &runsvc.RuntimePolicy{
211
- KeepTerminalsAlive: cfg.KeepTerminalsAlive,
212
- KeepSessionsAlive: cfg.KeepSessionsAlive,
213
- },
214
- })
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
+ }
215
252
  if err != nil {
216
- return fmt.Errorf("open engine: %w", err)
253
+ return fmt.Errorf("open %s engine: %w", cfg.ExecutorPlugin, err)
217
254
  }
218
255
 
219
256
  // 5.2 fail-fast preflight: before workers/pollers start, validate
@@ -276,7 +313,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
276
313
  // recovered runs exist before any poll cycle observes them. Mailbox
277
314
  // specs come from the engine so the recover path builds the same
278
315
  // description content as normal run execution.
279
- if recover {
316
+ if recover && cfg.ExecutorPlugin == "goworkflows" {
280
317
  specsFor := func(sys task.System, work runsvc.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
281
318
  return goworkflows.RenderMailboxSpecs(sys, work, wf)
282
319
  }
@@ -360,6 +397,18 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
360
397
  // single cleanup path.
361
398
  stopCtx, stopServe := context.WithCancel(context.Background())
362
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
+ }
363
412
 
364
413
  deps := &serveDeps{
365
414
  wf: wfSvc,
@@ -400,6 +449,29 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
400
449
  return serveResult
401
450
  }
402
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
+
403
475
  func workflowConfigValidator(repoReg *repo.Registry) func(context.Context, *workflow.Workflow) error {
404
476
  return func(ctx context.Context, wf *workflow.Workflow) error {
405
477
  nodeCfgs := map[string]config.RawValues{}
@@ -429,10 +501,19 @@ func (r repoExists) Exists(name string) bool {
429
501
 
430
502
  // serveDeps adapts composition-root services to server.Deps. Thin forwarder;
431
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
+
432
513
  type serveDeps struct {
433
514
  wf *workflow.Service
434
515
  repos *repo.Service
435
- engine *goworkflows.Engine
516
+ engine durableEngine
436
517
  runManager *runsvc.RunManager
437
518
  onReposChanged func()
438
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
+ }