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.
- package/README.md +20 -15
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/main.go +94 -13
- package/cmd/relay-flow/scenario_test.go +19 -2
- package/cmd/relay-flow/serve.go +98 -19
- package/cmd/relay-flow/serve_recovery_test.go +100 -0
- package/cmd/relay-flow/temporal_init.go +170 -0
- package/cmd/relay-flow/temporal_init_test.go +217 -0
- package/cmd/relay-flow/temporal_report_test.go +733 -0
- package/examples/config-reference.yaml +2 -2
- package/examples/minimal-beads-task-workflow.yaml +2 -1
- package/examples/workflow-reference.yaml +2 -1
- package/go.mod +37 -16
- package/go.sum +129 -61
- package/internal/config/machine.go +33 -1
- package/internal/config/machine_test.go +76 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/projection.go +47 -464
- package/internal/execution/projection/projection.go +867 -0
- package/internal/execution/projection/projection_test.go +347 -0
- package/internal/execution/temporal/activities.go +567 -0
- package/internal/execution/temporal/engine.go +384 -0
- package/internal/execution/temporal/engine_test.go +277 -0
- package/internal/execution/temporal/interpreter.go +736 -0
- package/internal/execution/temporal/operations.go +455 -0
- package/internal/execution/temporal/operations_test.go +101 -0
- package/internal/execution/temporal/recovery.go +194 -0
- package/internal/execution/temporal/recovery_runtime.go +41 -0
- package/internal/execution/temporal/recovery_test.go +102 -0
- package/internal/execution/temporal/snapshot_restart_test.go +72 -0
- package/internal/execution/temporal/spike_test.go +934 -0
- package/internal/execution/temporal/visibility_lag_test.go +415 -0
- package/internal/harness/opencode/opencode.go +3 -1
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +49 -46
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/runner/herdr/herdr.go +14 -0
- package/internal/runner/herdr/herdr_test.go +20 -0
- package/internal/runner/orca/orca.go +33 -0
- package/internal/runner/orca/orca_test.go +33 -4
- package/internal/runner/runner.go +8 -0
- 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, ¬Found) {
|
|
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
|
+
}
|