relay-flow 0.2.4-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 (45) hide show
  1. package/README.md +20 -15
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +94 -13
  4. package/cmd/relay-flow/scenario_test.go +19 -2
  5. package/cmd/relay-flow/serve.go +98 -19
  6. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  7. package/cmd/relay-flow/temporal_init.go +170 -0
  8. package/cmd/relay-flow/temporal_init_test.go +217 -0
  9. package/cmd/relay-flow/temporal_report_test.go +733 -0
  10. package/examples/config-reference.yaml +2 -2
  11. package/examples/minimal-beads-task-workflow.yaml +2 -1
  12. package/examples/workflow-reference.yaml +2 -1
  13. package/go.mod +37 -16
  14. package/go.sum +129 -61
  15. package/internal/config/machine.go +33 -1
  16. package/internal/config/machine_test.go +76 -0
  17. package/internal/execution/goworkflows/engine.go +13 -38
  18. package/internal/execution/goworkflows/projection.go +47 -464
  19. package/internal/execution/projection/projection.go +867 -0
  20. package/internal/execution/projection/projection_test.go +347 -0
  21. package/internal/execution/temporal/activities.go +567 -0
  22. package/internal/execution/temporal/engine.go +384 -0
  23. package/internal/execution/temporal/engine_test.go +277 -0
  24. package/internal/execution/temporal/interpreter.go +736 -0
  25. package/internal/execution/temporal/operations.go +455 -0
  26. package/internal/execution/temporal/operations_test.go +101 -0
  27. package/internal/execution/temporal/recovery.go +194 -0
  28. package/internal/execution/temporal/recovery_runtime.go +41 -0
  29. package/internal/execution/temporal/recovery_test.go +102 -0
  30. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  31. package/internal/execution/temporal/spike_test.go +934 -0
  32. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  33. package/internal/harness/opencode/opencode.go +3 -1
  34. package/internal/harness/opencode/opencode_test.go +1 -1
  35. package/internal/harness/opencode/repo_setup.go +1 -1
  36. package/internal/harness/pi/pi.go +49 -46
  37. package/internal/harness/pi/pi_test.go +26 -10
  38. package/internal/harness/pi/prompt_test.go +30 -1
  39. package/internal/harness/pi/validation_test.go +27 -51
  40. package/internal/runner/herdr/herdr.go +14 -0
  41. package/internal/runner/herdr/herdr_test.go +20 -0
  42. package/internal/runner/orca/orca.go +33 -0
  43. package/internal/runner/orca/orca_test.go +33 -4
  44. package/internal/runner/runner.go +8 -0
  45. package/package.json +1 -1
@@ -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
+ }