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.
- package/README.md +29 -18
- package/cmd/relay-flow/backend_selection_test.go +149 -0
- package/cmd/relay-flow/beads_composition_test.go +3 -3
- 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/beads-workflow.yaml +3 -0
- package/examples/config-reference.yaml +7 -3
- 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/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +13 -38
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/node_runtime_test.go +113 -4
- 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 +586 -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/harness.go +5 -0
- package/internal/harness/opencode/opencode.go +14 -3
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +57 -0
- package/internal/harness/pi/pi.go +58 -47
- package/internal/harness/pi/pi_test.go +26 -10
- package/internal/harness/pi/prompt_test.go +30 -1
- package/internal/harness/pi/task_env_test.go +51 -0
- package/internal/harness/pi/validation_test.go +27 -51
- package/internal/repo/service.go +18 -8
- 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/internal/task/beads/agent_env_test.go +52 -0
- package/internal/task/beads/beads.go +87 -7
- package/internal/task/beads/beads_test.go +78 -9
- package/internal/task/beads/repo_composition_test.go +47 -3
- package/internal/task/factory.go +31 -2
- package/internal/task/task.go +10 -0
- package/package.json +1 -1
package/cmd/relay-flow/serve.go
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
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 :=
|
|
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
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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, ¬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
|
+
}
|