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,384 @@
1
+ // Package temporal implements the Temporal durable executor. Temporal owns
2
+ // workflow history, signals, timers, and activity checkpoints; SQLite stores
3
+ // only the relay-owned derived projection.
4
+ package temporal
5
+
6
+ import (
7
+ "context"
8
+ "database/sql"
9
+ "errors"
10
+ "fmt"
11
+ "os"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
16
+ "github.com/rajpopat27/relay-flow/internal/harness"
17
+ "github.com/rajpopat27/relay-flow/internal/repo"
18
+ "github.com/rajpopat27/relay-flow/internal/run"
19
+ "github.com/rajpopat27/relay-flow/internal/runner"
20
+ "go.temporal.io/api/serviceerror"
21
+ workflowservice "go.temporal.io/api/workflowservice/v1"
22
+ "go.temporal.io/sdk/client"
23
+ temporalworker "go.temporal.io/sdk/worker"
24
+ "go.temporal.io/sdk/workflow"
25
+ _ "modernc.org/sqlite"
26
+ )
27
+
28
+ const (
29
+ TaskQueue = "relay-flow"
30
+ TicketWorkflowName = "TicketWorkflow"
31
+ minRetention = 30 * 24 * time.Hour
32
+ )
33
+
34
+ // Dependencies carries the replaceable boundaries used by Temporal
35
+ // activities. Recover is consumed only by Start and is never part of the
36
+ // workflow input snapshot.
37
+ type Dependencies struct {
38
+ Repos *repo.Registry
39
+ Runner runner.Runner
40
+ Harness harness.Harness
41
+ TaskSystem string
42
+ RetentionDays int
43
+ Runtime *run.RuntimePolicy
44
+ TemporalAddress string
45
+ TemporalNamespace string
46
+ Recover bool
47
+ }
48
+
49
+ // Engine implements both engine-neutral run boundaries.
50
+ type Engine struct {
51
+ db *sql.DB
52
+ runs *projection.RunProjection
53
+ deps Dependencies
54
+ runtime run.RuntimePolicy
55
+ retention time.Duration
56
+ namespaceRetention time.Duration
57
+
58
+ client client.Client
59
+ worker temporalworker.Worker
60
+ activities *Activities
61
+
62
+ mu sync.Mutex
63
+ lifecycleMu sync.Mutex
64
+ started bool
65
+ shutdownOnce sync.Once
66
+ fatal chan error
67
+ }
68
+
69
+ // InitDatabase initializes only the shared relay projection. It does not
70
+ // contact Temporal or create an engine-owned history schema.
71
+ func InitDatabase(path string) error {
72
+ db, err := openDatabase(path)
73
+ if err != nil {
74
+ return err
75
+ }
76
+ return db.Close()
77
+ }
78
+
79
+ // HasNonterminalRuns inspects the shared projection without migrating it.
80
+ func HasNonterminalRuns(path string) (bool, error) { return projection.HasNonterminalRuns(path) }
81
+
82
+ func openDatabase(path string) (*sql.DB, error) {
83
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_txlock=immediate", path))
84
+ if err != nil {
85
+ return nil, fmt.Errorf("open %s: %w", path, err)
86
+ }
87
+ db.SetMaxOpenConns(1)
88
+ if _, err := db.Exec(`PRAGMA schema_version`); err != nil {
89
+ db.Close()
90
+ return nil, fmt.Errorf("open %s: %w", path, err)
91
+ }
92
+ if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
93
+ db.Close()
94
+ return nil, fmt.Errorf("open %s: %w", path, err)
95
+ }
96
+ if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil {
97
+ db.Close()
98
+ return nil, fmt.Errorf("open %s: %w", path, err)
99
+ }
100
+ if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
101
+ db.Close()
102
+ return nil, fmt.Errorf("chmod %s: %w", path, err)
103
+ }
104
+ proj := &projection.RunProjection{DB: db}
105
+ if err := proj.Migrate(); err != nil {
106
+ db.Close()
107
+ return nil, fmt.Errorf("migrate relay projection: %w", err)
108
+ }
109
+ return db, nil
110
+ }
111
+
112
+ func validateDependencies(deps Dependencies) error {
113
+ if deps.Repos == nil || deps.Runner == nil || deps.Harness == nil {
114
+ return errors.New("temporal: Repos, Runner, and Harness dependencies are required")
115
+ }
116
+ if deps.Runtime != nil && deps.Runtime.KeepTerminalsAlive && !deps.Runtime.KeepSessionsAlive {
117
+ return errors.New("temporal: keepTerminalsAlive requires keepSessionsAlive")
118
+ }
119
+ if deps.TemporalAddress == "" {
120
+ return errors.New("temporal: TemporalAddress is required")
121
+ }
122
+ if deps.TemporalNamespace == "" {
123
+ return errors.New("temporal: TemporalNamespace is required")
124
+ }
125
+ if deps.TemporalNamespace == client.DefaultNamespace {
126
+ return fmt.Errorf("temporal: TemporalNamespace must be a dedicated named namespace, not %q", client.DefaultNamespace)
127
+ }
128
+ return nil
129
+ }
130
+
131
+ // New opens and migrates the relay projection, then verifies the immutable
132
+ // installation identity. A recovery construction creates the fresh marker
133
+ // after serve has moved the old projection aside; normal construction fails
134
+ // closed when the marker is missing or mismatched.
135
+ func New(path string, deps Dependencies) (*Engine, error) {
136
+ if err := validateDependencies(deps); err != nil {
137
+ return nil, err
138
+ }
139
+ db, err := openDatabase(path)
140
+ if err != nil {
141
+ return nil, err
142
+ }
143
+ proj := &projection.RunProjection{DB: db}
144
+ identity := projection.ExecutorIdentity{
145
+ ExecutorPlugin: "temporal", TemporalAddress: deps.TemporalAddress, TemporalNamespace: deps.TemporalNamespace,
146
+ }
147
+ identityErr := proj.VerifyIdentity(context.Background(), identity)
148
+ if deps.Recover {
149
+ identityErr = proj.InitializeIdentity(context.Background(), identity)
150
+ }
151
+ if identityErr != nil {
152
+ db.Close()
153
+ return nil, identityErr
154
+ }
155
+ retention := minRetention
156
+ if deps.RetentionDays > 0 {
157
+ retention = time.Duration(deps.RetentionDays) * 24 * time.Hour
158
+ }
159
+ namespaceRetention := minRetention
160
+ if retention > namespaceRetention {
161
+ namespaceRetention = retention
162
+ }
163
+ runtimePolicy := run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true}
164
+ if deps.Runtime != nil {
165
+ runtimePolicy = *deps.Runtime
166
+ }
167
+ return &Engine{
168
+ db: db, runs: proj, deps: deps, runtime: runtimePolicy, retention: retention,
169
+ namespaceRetention: namespaceRetention, fatal: make(chan error, 1),
170
+ }, nil
171
+ }
172
+
173
+ func temporalClientOptions(deps Dependencies) client.Options {
174
+ return client.Options{HostPort: deps.TemporalAddress, Namespace: deps.TemporalNamespace}
175
+ }
176
+
177
+ func (e *Engine) dial(ctx context.Context) error {
178
+ if e.client != nil {
179
+ return nil
180
+ }
181
+ c, err := client.Dial(temporalClientOptions(e.deps))
182
+ if err != nil {
183
+ return fmt.Errorf("dial Temporal %s/%s: %w", e.deps.TemporalAddress, e.deps.TemporalNamespace, err)
184
+ }
185
+ e.client = c
186
+ return nil
187
+ }
188
+
189
+ func (e *Engine) verifyNamespace(ctx context.Context) error {
190
+ description, err := e.client.WorkflowService().DescribeNamespace(ctx, &workflowservice.DescribeNamespaceRequest{Namespace: e.deps.TemporalNamespace})
191
+ if err != nil {
192
+ return fmt.Errorf("describe Temporal namespace %q: %w", e.deps.TemporalNamespace, err)
193
+ }
194
+ if description == nil || description.Config == nil || description.Config.WorkflowExecutionRetentionTtl == nil {
195
+ return fmt.Errorf("Temporal namespace %q has no workflow retention policy", e.deps.TemporalNamespace)
196
+ }
197
+ if description.Config.WorkflowExecutionRetentionTtl.AsDuration() < e.namespaceRetention {
198
+ return fmt.Errorf("Temporal namespace %q retention %s is below required %s", e.deps.TemporalNamespace, description.Config.WorkflowExecutionRetentionTtl.AsDuration(), e.namespaceRetention)
199
+ }
200
+ return nil
201
+ }
202
+
203
+ // waitNamespaceReady handles the local server's short frontend-cache window
204
+ // after namespace registration. It probes only public Visibility APIs and
205
+ // never creates or mutates Temporal state.
206
+ func (e *Engine) waitNamespaceReady(ctx context.Context) error {
207
+ for {
208
+ _, err := e.client.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{})
209
+ if err == nil {
210
+ return nil
211
+ }
212
+ var notFound *serviceerror.NamespaceNotFound
213
+ if !errors.As(err, &notFound) {
214
+ return fmt.Errorf("probe Temporal namespace %q: %w", e.deps.TemporalNamespace, err)
215
+ }
216
+ timer := time.NewTimer(200 * time.Millisecond)
217
+ select {
218
+ case <-ctx.Done():
219
+ timer.Stop()
220
+ return ctx.Err()
221
+ case <-timer.C:
222
+ }
223
+ }
224
+ }
225
+
226
+ func stopWorker(w temporalworker.Worker) {
227
+ if w == nil {
228
+ return
229
+ }
230
+ defer func() { _ = recover() }()
231
+ w.Stop()
232
+ }
233
+
234
+ func workerOptions(onFatal func(error), localOnly bool) temporalworker.Options {
235
+ return temporalworker.Options{
236
+ MaxConcurrentWorkflowTaskExecutionSize: 10,
237
+ MaxConcurrentActivityExecutionSize: 20,
238
+ MaxConcurrentWorkflowTaskPollers: 2,
239
+ MaxConcurrentActivityTaskPollers: 2,
240
+ WorkerStopTimeout: 30 * time.Second,
241
+ OnFatalError: onFatal,
242
+ LocalActivityWorkerOnly: localOnly,
243
+ }
244
+ }
245
+
246
+ func (e *Engine) newWorker(localOnly bool) temporalworker.Worker {
247
+ w := temporalworker.New(e.client, TaskQueue, workerOptions(func(err error) {
248
+ select {
249
+ case e.fatal <- err:
250
+ default:
251
+ }
252
+ }, localOnly))
253
+ w.RegisterWorkflowWithOptions(TicketWorkflow, workflow.RegisterOptions{Name: TicketWorkflowName})
254
+ w.RegisterActivity(e.activities)
255
+ return w
256
+ }
257
+
258
+ // Start connects to the configured namespace, verifies retention, rebuilds a
259
+ // projection when explicitly requested, and starts one aggregate worker.
260
+ func (e *Engine) Start(ctx context.Context) error {
261
+ e.lifecycleMu.Lock()
262
+ defer e.lifecycleMu.Unlock()
263
+ e.mu.Lock()
264
+ if e.started {
265
+ e.mu.Unlock()
266
+ return nil
267
+ }
268
+ e.mu.Unlock()
269
+ if err := e.dial(ctx); err != nil {
270
+ return err
271
+ }
272
+ if err := e.verifyNamespace(ctx); err != nil {
273
+ e.client.Close()
274
+ e.client = nil
275
+ return err
276
+ }
277
+ if err := e.waitNamespaceReady(ctx); err != nil {
278
+ e.client.Close()
279
+ e.client = nil
280
+ return err
281
+ }
282
+ e.activities = &Activities{
283
+ Repos: e.deps.Repos, Runner: e.deps.Runner, Harness: e.deps.Harness,
284
+ TaskSystem: e.deps.TaskSystem, Runs: e.runs,
285
+ }
286
+ if e.deps.Recover {
287
+ if err := e.rebuildProjection(ctx); err != nil {
288
+ e.client.Close()
289
+ e.client = nil
290
+ return err
291
+ }
292
+ // Recovery retention is projection-only and must finish before the
293
+ // normal worker is allowed to resume pending workflow tasks.
294
+ if _, err := e.runs.SweepRetention(ctx, time.Now().UTC().Add(-e.retention)); err != nil {
295
+ e.client.Close()
296
+ e.client = nil
297
+ return fmt.Errorf("recovery retention sweep: %w", err)
298
+ }
299
+ }
300
+ w := e.newWorker(false)
301
+ if err := w.Start(); err != nil {
302
+ stopWorker(w)
303
+ e.client.Close()
304
+ e.client = nil
305
+ return fmt.Errorf("start Temporal worker: %w", err)
306
+ }
307
+ e.worker = w
308
+ if !e.deps.Recover {
309
+ if _, err := e.runs.SweepRetention(ctx, time.Now().UTC().Add(-e.retention)); err != nil {
310
+ stopWorker(w)
311
+ e.client.Close()
312
+ e.client = nil
313
+ return fmt.Errorf("retention sweep: %w", err)
314
+ }
315
+ }
316
+ e.mu.Lock()
317
+ e.started = true
318
+ e.mu.Unlock()
319
+ return nil
320
+ }
321
+
322
+ // Shutdown is idempotent and closes worker, Temporal client, and projection
323
+ // database in that order.
324
+ func (e *Engine) Shutdown(context.Context) error {
325
+ e.lifecycleMu.Lock()
326
+ defer e.lifecycleMu.Unlock()
327
+ var err error
328
+ e.shutdownOnce.Do(func() {
329
+ e.mu.Lock()
330
+ w := e.worker
331
+ c := e.client
332
+ e.worker = nil
333
+ e.client = nil
334
+ e.started = false
335
+ e.mu.Unlock()
336
+ if w != nil {
337
+ stopWorker(w)
338
+ }
339
+ if c != nil {
340
+ c.Close()
341
+ }
342
+ if e.db != nil {
343
+ err = e.db.Close()
344
+ }
345
+ })
346
+ return err
347
+ }
348
+
349
+ // FatalErrors exposes worker-fatal errors to the composition root. It is
350
+ // intentionally advisory; the selected server lifecycle remains responsible
351
+ // for deciding when to stop pollers and the socket.
352
+ func (e *Engine) FatalErrors() <-chan error { return e.fatal }
353
+
354
+ // GetNodeRuntime returns one persisted runtime binding.
355
+ func (e *Engine) GetNodeRuntime(ctx context.Context, id run.ID, node string) (projection.NodeRuntime, error) {
356
+ return e.runs.GetNodeRuntime(ctx, id, node)
357
+ }
358
+
359
+ func (e *Engine) GetRun(ctx context.Context, id run.ID) (run.Run, error) {
360
+ return e.runs.Get(ctx, id)
361
+ }
362
+ func (e *Engine) FindRunByTicket(ctx context.Context, ticket string) (run.Run, error) {
363
+ return e.runs.FindByTicket(ctx, ticket)
364
+ }
365
+ func (e *Engine) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
366
+ return e.runs.List(ctx, filter)
367
+ }
368
+ func (e *Engine) HasActiveWorkflow(ctx context.Context, name string) (bool, error) {
369
+ return e.runs.HasActiveWorkflow(ctx, name)
370
+ }
371
+ func (e *Engine) HasActiveRepo(ctx context.Context, name string) (bool, error) {
372
+ return e.runs.HasActiveRepo(ctx, name)
373
+ }
374
+ func (e *Engine) HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error) {
375
+ return e.runs.HasProcessedReport(ctx, id, reportID)
376
+ }
377
+ func (e *Engine) RegisterNodeSession(ctx context.Context, r run.NodeRuntimeRegistration) (run.NodeRuntimeRegistrationAck, error) {
378
+ accepted, err := e.runs.RegisterNodeSession(ctx, r)
379
+ return run.NodeRuntimeRegistrationAck{Accepted: accepted}, err
380
+ }
381
+
382
+ // Compile-time contract checks.
383
+ var _ run.Executor = (*Engine)(nil)
384
+ var _ run.RunQueries = (*Engine)(nil)
@@ -0,0 +1,277 @@
1
+ package temporal
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "os"
8
+ "path/filepath"
9
+ "reflect"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
15
+ "github.com/rajpopat27/relay-flow/internal/identity"
16
+ "github.com/rajpopat27/relay-flow/internal/repo"
17
+ "github.com/rajpopat27/relay-flow/internal/retry"
18
+ "github.com/rajpopat27/relay-flow/internal/run"
19
+ "github.com/rajpopat27/relay-flow/internal/task"
20
+ "github.com/rajpopat27/relay-flow/internal/workflow"
21
+ workflowservice "go.temporal.io/api/workflowservice/v1"
22
+ "go.temporal.io/sdk/client"
23
+ temporalSDK "go.temporal.io/sdk/temporal"
24
+ "google.golang.org/protobuf/types/known/durationpb"
25
+ _ "modernc.org/sqlite"
26
+ )
27
+
28
+ func TestTemporalNewRejectsIdentityMismatch(t *testing.T) {
29
+ path := filepath.Join(t.TempDir(), "state.db")
30
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "identity-test"}); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ _, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "other-host:7233", TemporalNamespace: "identity-test"})
34
+ if !errors.Is(err, projection.ErrIdentityMismatch) {
35
+ t.Fatalf("identity mismatch error = %v", err)
36
+ }
37
+ }
38
+
39
+ func TestTemporalNewRejectsLegacyMarkerlessProjection(t *testing.T) {
40
+ path := filepath.Join(t.TempDir(), "state.db")
41
+ if err := projection.InitDatabase(path); err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ _, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "localhost:7233", TemporalNamespace: "legacy-test"})
45
+ if !errors.Is(err, projection.ErrIdentityMissing) {
46
+ t.Fatalf("legacy markerless error = %v", err)
47
+ }
48
+ }
49
+
50
+ func TestTemporalWorkflowAndActivityRegistrationAreSeparate(t *testing.T) {
51
+ if _, ok := reflect.TypeOf(Activities{}).MethodByName("TicketWorkflow"); ok {
52
+ t.Fatal("Temporal activity struct exposes TicketWorkflow")
53
+ }
54
+ if reflect.TypeOf(TicketWorkflow).Kind() != reflect.Func {
55
+ t.Fatal("TicketWorkflow is not a package-level function")
56
+ }
57
+ }
58
+
59
+ func TestTemporalWorkerAndActivityOptionsMatchMVP(t *testing.T) {
60
+ opts := workerOptions(nil, false)
61
+ if opts.MaxConcurrentWorkflowTaskExecutionSize != 10 || opts.MaxConcurrentActivityExecutionSize != 20 {
62
+ t.Fatalf("execution limits = workflow %d/activity %d", opts.MaxConcurrentWorkflowTaskExecutionSize, opts.MaxConcurrentActivityExecutionSize)
63
+ }
64
+ if opts.MaxConcurrentWorkflowTaskPollers != 2 || opts.MaxConcurrentActivityTaskPollers != 2 {
65
+ t.Fatalf("poller limits = workflow %d/activity %d", opts.MaxConcurrentWorkflowTaskPollers, opts.MaxConcurrentActivityTaskPollers)
66
+ }
67
+ if opts.WorkerStopTimeout != 30*time.Second || opts.LocalActivityWorkerOnly {
68
+ t.Fatalf("normal worker options = %+v", opts)
69
+ }
70
+ activity := temporalActivityOptions
71
+ if activity.StartToCloseTimeout != 5*time.Minute || !activity.WaitForCancellation || activity.RetryPolicy == nil || activity.RetryPolicy.MaximumAttempts != 1 {
72
+ t.Fatalf("activity options = %+v", activity)
73
+ }
74
+ }
75
+
76
+ func TestRuntimeBindingCleanupUpdatesTemporalSnapshot(t *testing.T) {
77
+ state := &workflowState{bindings: map[string]NodeRuntimeBinding{
78
+ "node": {Node: "node", TerminalID: "term", SessionID: "session", NodeVisitID: "visit"},
79
+ }}
80
+ applyRuntimePolicy(state, "node", run.RuntimePolicy{})
81
+ binding := state.bindings["node"]
82
+ if binding.TerminalID != "" || binding.SessionID != "" {
83
+ t.Fatalf("cleared runtime binding = %+v", binding)
84
+ }
85
+ state.bindings["node"] = NodeRuntimeBinding{Node: "node", TerminalID: "term", SessionID: "session", NodeVisitID: "visit"}
86
+ applyRuntimePolicy(state, "node", run.RuntimePolicy{KeepTerminalsAlive: true, KeepSessionsAlive: true})
87
+ if got := state.bindings["node"]; got.TerminalID != "term" || got.SessionID != "session" {
88
+ t.Fatalf("preserved runtime binding = %+v", got)
89
+ }
90
+ }
91
+
92
+ func TestTemporalClientOptionsUseConfiguredIdentity(t *testing.T) {
93
+ options := temporalClientOptions(Dependencies{TemporalAddress: "127.0.0.1:7233", TemporalNamespace: "relay-test"})
94
+ if options.HostPort != "127.0.0.1:7233" || options.Namespace != "relay-test" {
95
+ t.Fatalf("Temporal client options = %+v", options)
96
+ }
97
+ }
98
+
99
+ func TestClassifyTemporalActivityErrors(t *testing.T) {
100
+ conflict := temporalSDK.NewApplicationError("manual conflict", string(retry.Conflict))
101
+ if got := classifyTemporalError(conflict); got.Kind != retry.Conflict {
102
+ t.Fatalf("Temporal conflict classification = %+v", got)
103
+ }
104
+ transient := temporalSDK.NewApplicationError("temporary failure", string(retry.Transient))
105
+ if got := classifyTemporalError(transient); got.Kind != retry.Transient {
106
+ t.Fatalf("Temporal transient classification = %+v", got)
107
+ }
108
+ wrapped := retry.ConflictError(errors.New("wrapped conflict"))
109
+ if got := classifyTemporalError(wrapped); got.Kind != retry.Conflict {
110
+ t.Fatalf("wrapped conflict classification = %+v", got)
111
+ }
112
+ }
113
+
114
+ func TestTemporalRetentionSeparatesLocalAndNamespacePolicies(t *testing.T) {
115
+ path := filepath.Join(t.TempDir(), "state.db")
116
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "retention-separation"}); err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ engine, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, RetentionDays: 1, TemporalAddress: "localhost:7233", TemporalNamespace: "retention-separation"})
120
+ if err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ if engine.retention != 24*time.Hour || engine.namespaceRetention != 30*24*time.Hour {
124
+ t.Fatalf("retention policies = local %s/namespace %s", engine.retention, engine.namespaceRetention)
125
+ }
126
+ if err := engine.Shutdown(context.Background()); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ }
130
+
131
+ func TestTemporalStartRejectsShortRetentionNamespace(t *testing.T) {
132
+ if os.Getenv("RELAY_FLOW_TEMPORAL_LIVE") != "1" {
133
+ t.Skip("set RELAY_FLOW_TEMPORAL_LIVE=1 to run namespace retention startup coverage")
134
+ }
135
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
136
+ defer cancel()
137
+ namespace := "relay-flow-short-retention-" + string(identity.NewNodeVisitID())[:12]
138
+ manager, err := client.NewNamespaceClient(client.Options{HostPort: "localhost:7233"})
139
+ if err != nil {
140
+ t.Fatal(err)
141
+ }
142
+ defer manager.Close()
143
+ if err := manager.Register(ctx, &workflowservice.RegisterNamespaceRequest{
144
+ Namespace: namespace, Description: "short retention test", WorkflowExecutionRetentionPeriod: durationpb.New(24 * time.Hour),
145
+ }); err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ path := filepath.Join(t.TempDir(), "state.db")
149
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: namespace}); err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ engine, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "localhost:7233", TemporalNamespace: namespace})
153
+ if err != nil {
154
+ t.Fatal(err)
155
+ }
156
+ if err := engine.Start(ctx); err == nil || !strings.Contains(err.Error(), "below required") {
157
+ t.Fatalf("Start short-retention namespace error = %v", err)
158
+ }
159
+ if engine.worker != nil {
160
+ t.Fatal("short-retention startup created a worker")
161
+ }
162
+ if err := engine.Shutdown(context.Background()); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ if db, err := sql.Open("sqlite", path); err != nil {
166
+ t.Fatal(err)
167
+ } else {
168
+ _ = db.Close()
169
+ }
170
+ }
171
+
172
+ func TestTemporalUnavailableFailsWithoutEmbeddedFallback(t *testing.T) {
173
+ path := filepath.Join(t.TempDir(), "state.db")
174
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "127.0.0.1:1", TemporalNamespace: "unavailable-test"}); err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ engine, err := New(path, Dependencies{Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{}, TemporalAddress: "127.0.0.1:1", TemporalNamespace: "unavailable-test"})
178
+ if err != nil {
179
+ t.Fatal(err)
180
+ }
181
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
182
+ defer cancel()
183
+ if err := engine.Start(ctx); err == nil {
184
+ t.Fatal("unavailable Temporal endpoint unexpectedly started")
185
+ }
186
+ if engine.worker != nil {
187
+ t.Fatal("unavailable Temporal endpoint started a worker")
188
+ }
189
+ if err := engine.Shutdown(context.Background()); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ db, err := sql.Open("sqlite", path)
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ defer db.Close()
197
+ var embedded int
198
+ if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='instances'`).Scan(&embedded); err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ if embedded != 0 {
202
+ t.Fatal("unavailable Temporal startup created embedded go-workflows tables")
203
+ }
204
+ }
205
+
206
+ func TestTemporalLocalRetentionKeepsActiveRows(t *testing.T) {
207
+ path := filepath.Join(t.TempDir(), "state.db")
208
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "retention-test"}); err != nil {
209
+ t.Fatal(err)
210
+ }
211
+ db, err := sql.Open("sqlite", path)
212
+ if err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ defer db.Close()
216
+ proj := &projection.RunProjection{DB: db}
217
+ old := run.Start{ID: "repo/wf/old", Repo: "repo", Workflow: workflow.Workflow{Name: "wf"}, Ticket: task.TicketRef{ID: "old", Key: "OLD"}}
218
+ active := run.Start{ID: "repo/wf/active", Repo: "repo", Workflow: workflow.Workflow{Name: "wf"}, Ticket: task.TicketRef{ID: "active", Key: "ACTIVE"}}
219
+ if err := proj.InsertStart(context.Background(), old, time.Now().UTC().Add(-72*time.Hour)); err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ if err := proj.InsertStart(context.Background(), active, time.Now().UTC()); err != nil {
223
+ t.Fatal(err)
224
+ }
225
+ finished := time.Now().UTC().Add(-48 * time.Hour)
226
+ if err := proj.UpdateState(context.Background(), old.ID, run.StateCompleted, "", &finished); err != nil {
227
+ t.Fatal(err)
228
+ }
229
+ if err := proj.UpdateState(context.Background(), active.ID, run.StateWaiting, "", nil); err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ removed, err := proj.SweepRetention(context.Background(), time.Now().UTC().Add(-24*time.Hour))
233
+ if err != nil {
234
+ t.Fatal(err)
235
+ }
236
+ if len(removed) != 1 || removed[0] != string(old.ID) {
237
+ t.Fatalf("retention removed = %v", removed)
238
+ }
239
+ if _, err := proj.Get(context.Background(), active.ID); err != nil {
240
+ t.Fatalf("active row removed by retention: %v", err)
241
+ }
242
+ if _, err := proj.Get(context.Background(), old.ID); !projection.IsNotFound(err) {
243
+ t.Fatalf("old row remains after retention: %v", err)
244
+ }
245
+ }
246
+
247
+ func TestTemporalEngineShutdownIsIdempotent(t *testing.T) {
248
+ path := filepath.Join(t.TempDir(), "state.db")
249
+ if err := projection.InitDatabaseWithIdentity(path, projection.ExecutorIdentity{
250
+ ExecutorPlugin: "temporal", TemporalAddress: "localhost:7233", TemporalNamespace: "shutdown-test",
251
+ }); err != nil {
252
+ t.Fatal(err)
253
+ }
254
+ deps := Dependencies{
255
+ Repos: repo.NewRegistry(), Runner: &lagRunner{}, Harness: &lagHarness{},
256
+ TemporalAddress: "localhost:7233", TemporalNamespace: "shutdown-test",
257
+ }
258
+ engine, err := New(path, deps)
259
+ if err != nil {
260
+ t.Fatal(err)
261
+ }
262
+ if err := engine.Shutdown(context.Background()); err != nil {
263
+ t.Fatal(err)
264
+ }
265
+ if err := engine.Shutdown(context.Background()); err != nil {
266
+ t.Fatalf("second Shutdown = %v", err)
267
+ }
268
+ db, err := sql.Open("sqlite", path)
269
+ if err != nil {
270
+ t.Fatal(err)
271
+ }
272
+ defer db.Close()
273
+ var one int
274
+ if err := db.QueryRow(`SELECT 1`).Scan(&one); err != nil || one != 1 {
275
+ t.Fatalf("database after idempotent shutdown = %d, %v", one, err)
276
+ }
277
+ }