mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat(web): initialize Next.js project with Tailwind CSS and TypeScript setup
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// Package durability defines the journal abstraction the engine uses to
|
||||
// turn each meaningful step (node execution, sub-workflow call, approval wait)
|
||||
// into a replayable journal entry.
|
||||
//
|
||||
// Two implementations ship: RestateDurableCtx for production durability and
|
||||
// DirectCtx for tests / library-mode embedding without a Restate process.
|
||||
package durability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryPolicy configures per-step retry behavior. In Restate this maps to
|
||||
// native per-Run retry options. In DirectCtx (tests) it retries in-process.
|
||||
type RetryPolicy struct {
|
||||
MaxAttempts int
|
||||
InitialInterval time.Duration
|
||||
}
|
||||
|
||||
// DurableCtx abstracts over journaled execution.
|
||||
type DurableCtx interface {
|
||||
// Run executes fn as a named journaled step. On replay (after crash/restart),
|
||||
// completed steps return cached results without re-execution.
|
||||
// The returned value must be JSON-serializable.
|
||||
Run(name string, fn func(ctx context.Context) (any, error)) (any, error)
|
||||
|
||||
// RunWithRetry is like Run but applies a per-step retry policy.
|
||||
RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error)
|
||||
}
|
||||
|
||||
type durableCtxKey struct{}
|
||||
|
||||
// WithDurableCtx attaches a DurableCtx to a context.Context.
|
||||
func WithDurableCtx(ctx context.Context, dctx DurableCtx) context.Context {
|
||||
return context.WithValue(ctx, durableCtxKey{}, dctx)
|
||||
}
|
||||
|
||||
// FromContext extracts a DurableCtx from the context, if present.
|
||||
func FromContext(ctx context.Context) (DurableCtx, bool) {
|
||||
dctx, ok := ctx.Value(durableCtxKey{}).(DurableCtx)
|
||||
return dctx, ok && dctx != nil
|
||||
}
|
||||
|
||||
// --- Restate context threading ---
|
||||
// Separate from DurableCtx because some executors need the raw Restate context
|
||||
// for features not available through the DurableCtx abstraction (e.g., Awakeables).
|
||||
|
||||
type restateCtxKey struct{}
|
||||
|
||||
// WithRestateCtx attaches a raw Restate WorkflowContext to a Go context.
|
||||
// Used by the ApprovalExecutor to create Awakeables.
|
||||
func WithRestateCtx(ctx context.Context, rctx any) context.Context {
|
||||
return context.WithValue(ctx, restateCtxKey{}, rctx)
|
||||
}
|
||||
|
||||
// RestateCtxFromContext extracts the raw Restate WorkflowContext.
|
||||
// Returns nil if not running inside Restate.
|
||||
func RestateCtxFromContext(ctx context.Context) any {
|
||||
return ctx.Value(restateCtxKey{})
|
||||
}
|
||||
|
||||
// ScopedDurableCtx wraps a DurableCtx and prefixes all step names.
|
||||
// Used when a sub-workflow runs inside a parent durable workflow — the prefix
|
||||
// avoids step name collisions between parent and child.
|
||||
type ScopedDurableCtx struct {
|
||||
Inner DurableCtx
|
||||
Prefix string
|
||||
}
|
||||
|
||||
func (s *ScopedDurableCtx) Run(name string, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
return s.Inner.Run(s.Prefix+name, fn)
|
||||
}
|
||||
|
||||
func (s *ScopedDurableCtx) RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
return s.Inner.RunWithRetry(s.Prefix+name, policy, fn)
|
||||
}
|
||||
|
||||
// RunAs executes a durable step and JSON-decodes the result into a concrete type T.
|
||||
// Restate's `restate.Run` returns map[string]interface{} on replay instead of the
|
||||
// original struct type; RunAs handles that round-trip transparently.
|
||||
func RunAs[T any](dctx DurableCtx, name string, fn func(ctx context.Context) (T, error)) (T, error) {
|
||||
raw, err := dctx.Run(name, func(ctx context.Context) (any, error) {
|
||||
return fn(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
if typed, ok := raw.(T); ok {
|
||||
return typed, nil
|
||||
}
|
||||
jsonBytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("durability RunAs %q: marshal: %w", name, err)
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("durability RunAs %q: unmarshal into %T: %w", name, result, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RunAsWithRetry is like RunAs but applies a per-step retry policy.
|
||||
func RunAsWithRetry[T any](dctx DurableCtx, name string, policy RetryPolicy, fn func(ctx context.Context) (T, error)) (T, error) {
|
||||
raw, err := dctx.RunWithRetry(name, policy, func(ctx context.Context) (any, error) {
|
||||
return fn(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
if typed, ok := raw.(T); ok {
|
||||
return typed, nil
|
||||
}
|
||||
jsonBytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("durability RunAsWithRetry %q: marshal: %w", name, err)
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("durability RunAsWithRetry %q: unmarshal into %T: %w", name, result, err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Approval persistence interface ---
|
||||
|
||||
// ApprovalCreator persists a HITL approval row. Implemented by the storage layer.
|
||||
// Defined here to avoid a cycle from executors → storage.
|
||||
type ApprovalCreator interface {
|
||||
CreateFromRecord(ctx context.Context, a *ApprovalRecord) error
|
||||
}
|
||||
|
||||
// ApprovalRecord is the data needed to persist a pending approval.
|
||||
type ApprovalRecord struct {
|
||||
ID string
|
||||
ExecutionID string
|
||||
NodeName string
|
||||
AwakeableID string
|
||||
Status string
|
||||
InputData map[string]any
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type approvalCreatorKey struct{}
|
||||
type executionIDKey struct{}
|
||||
type apiKeyCtxKey struct{}
|
||||
|
||||
// WithApprovalCreator attaches an ApprovalCreator to a context.
|
||||
func WithApprovalCreator(ctx context.Context, c ApprovalCreator) context.Context {
|
||||
return context.WithValue(ctx, approvalCreatorKey{}, c)
|
||||
}
|
||||
|
||||
// ApprovalCreatorFromContext extracts the ApprovalCreator, or nil.
|
||||
func ApprovalCreatorFromContext(ctx context.Context) ApprovalCreator {
|
||||
c, _ := ctx.Value(approvalCreatorKey{}).(ApprovalCreator)
|
||||
return c
|
||||
}
|
||||
|
||||
// WithExecutionID attaches the workflow execution ID to a context.
|
||||
func WithExecutionID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, executionIDKey{}, id)
|
||||
}
|
||||
|
||||
// ExecutionIDFromContext extracts the execution ID, or "".
|
||||
func ExecutionIDFromContext(ctx context.Context) string {
|
||||
s, _ := ctx.Value(executionIDKey{}).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// WithAPIKey attaches the API key to a context.
|
||||
func WithAPIKey(ctx context.Context, key string) context.Context {
|
||||
return context.WithValue(ctx, apiKeyCtxKey{}, key)
|
||||
}
|
||||
|
||||
// APIKeyFromContext extracts the API key, or "".
|
||||
func APIKeyFromContext(ctx context.Context) string {
|
||||
s, _ := ctx.Value(apiKeyCtxKey{}).(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package durability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DirectCtx executes functions inline without journaling.
|
||||
// Used in tests and for library-mode embedding (e.g., governor importing flow's
|
||||
// engine but not running a Restate process).
|
||||
type DirectCtx struct {
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
func (d *DirectCtx) ctx() context.Context {
|
||||
if d.Ctx != nil {
|
||||
return d.Ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (d *DirectCtx) Run(_ string, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
return fn(d.ctx())
|
||||
}
|
||||
|
||||
func (d *DirectCtx) RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
maxAttempts := policy.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
wait := policy.InitialInterval
|
||||
if wait <= 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
|
||||
ctx := d.ctx()
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
result, err := fn(ctx)
|
||||
if err == nil {
|
||||
if attempt > 0 {
|
||||
slog.InfoContext(ctx, "retry_succeeded",
|
||||
slog.String("step", name),
|
||||
slog.Int("attempt", attempt+1),
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt < maxAttempts-1 {
|
||||
slog.WarnContext(ctx, "retry",
|
||||
slog.String("step", name),
|
||||
slog.Int("attempt", attempt+1),
|
||||
slog.Int("max", maxAttempts),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package durability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDirectCtx_Run_passesThrough(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
out, err := d.Run("step", func(_ context.Context) (any, error) {
|
||||
return 42, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out != 42 {
|
||||
t.Fatalf("expected 42, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectCtx_RunWithRetry_succeedsOnSecondAttempt(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
calls := 0
|
||||
out, err := d.RunWithRetry("step", RetryPolicy{MaxAttempts: 3, InitialInterval: time.Millisecond}, func(_ context.Context) (any, error) {
|
||||
calls++
|
||||
if calls < 2 {
|
||||
return nil, errors.New("flaky")
|
||||
}
|
||||
return "ok", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", calls)
|
||||
}
|
||||
if out != "ok" {
|
||||
t.Fatalf("expected ok, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectCtx_RunWithRetry_givesUpAfterMaxAttempts(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
calls := 0
|
||||
_, err := d.RunWithRetry("step", RetryPolicy{MaxAttempts: 3, InitialInterval: time.Millisecond}, func(_ context.Context) (any, error) {
|
||||
calls++
|
||||
return nil, errors.New("permanent")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausting retries")
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("expected 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectCtx_RunWithRetry_zeroAttemptsDefaultsToOne(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
calls := 0
|
||||
_, err := d.RunWithRetry("step", RetryPolicy{}, func(_ context.Context) (any, error) {
|
||||
calls++
|
||||
return nil, errors.New("nope")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected single attempt, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDurableCtx_roundTrip(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
ctx := WithDurableCtx(context.Background(), d)
|
||||
got, ok := FromContext(ctx)
|
||||
if !ok || got != d {
|
||||
t.Fatal("DurableCtx round-trip failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContext_nilCtx(t *testing.T) {
|
||||
if _, ok := FromContext(context.Background()); ok {
|
||||
t.Fatal("FromContext should be false on a bare context")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package durability
|
||||
|
||||
import "errors"
|
||||
|
||||
// TerminalClassifier is implemented by errors that know whether they are permanent.
|
||||
// RestateDurableCtx checks this interface: errors marked terminal are wrapped with
|
||||
// restate.TerminalError so Restate stops retrying immediately.
|
||||
type TerminalClassifier interface {
|
||||
IsTerminal() bool
|
||||
}
|
||||
|
||||
// PermanentError marks an error as terminal for durable execution.
|
||||
// Use this to wrap errors that should never be retried (e.g., resource not found,
|
||||
// invalid configuration) without importing Restate.
|
||||
type PermanentError struct{ Err error }
|
||||
|
||||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||
func (e *PermanentError) IsTerminal() bool { return true }
|
||||
|
||||
// RetryableError marks an error as retryable for durable execution.
|
||||
// By default all errors are terminal (no retry). Wrap with this to allow
|
||||
// Restate to retry (e.g., transient network errors, rate limits).
|
||||
type RetryableError struct{ Err error }
|
||||
|
||||
func (e *RetryableError) Error() string { return e.Err.Error() }
|
||||
func (e *RetryableError) Unwrap() error { return e.Err }
|
||||
func (e *RetryableError) IsTerminal() bool { return false }
|
||||
|
||||
// IsTerminalError checks whether err should be treated as terminal.
|
||||
// Returns true unless the error (or any in its chain) is explicitly marked
|
||||
// retryable via RetryableError.
|
||||
func IsTerminalError(err error) bool {
|
||||
var tc TerminalClassifier
|
||||
if errors.As(err, &tc) {
|
||||
return tc.IsTerminal()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRetryableError checks whether err is explicitly marked as retryable.
|
||||
func IsRetryableError(err error) bool {
|
||||
var tc TerminalClassifier
|
||||
return errors.As(err, &tc) && !tc.IsTerminal()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package durability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTerminalError_default(t *testing.T) {
|
||||
if !IsTerminalError(errors.New("plain")) {
|
||||
t.Fatal("plain errors should default to terminal (no retry)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentError_isTerminal(t *testing.T) {
|
||||
err := &PermanentError{Err: errors.New("nope")}
|
||||
if !IsTerminalError(err) {
|
||||
t.Fatal("PermanentError must be terminal")
|
||||
}
|
||||
if IsRetryableError(err) {
|
||||
t.Fatal("PermanentError must not be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableError_isNotTerminal(t *testing.T) {
|
||||
err := &RetryableError{Err: errors.New("transient")}
|
||||
if IsTerminalError(err) {
|
||||
t.Fatal("RetryableError must not be terminal")
|
||||
}
|
||||
if !IsRetryableError(err) {
|
||||
t.Fatal("RetryableError must be retryable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableError_unwrappedThroughFmt(t *testing.T) {
|
||||
inner := errors.New("network down")
|
||||
wrapped := fmt.Errorf("step bar: %w", &RetryableError{Err: inner})
|
||||
if IsTerminalError(wrapped) {
|
||||
t.Fatal("retryable classification must survive fmt.Errorf wrapping")
|
||||
}
|
||||
if !IsRetryableError(wrapped) {
|
||||
t.Fatal("retryable classification must survive fmt.Errorf wrapping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentError_unwrap(t *testing.T) {
|
||||
inner := errors.New("missing")
|
||||
err := &PermanentError{Err: inner}
|
||||
if !errors.Is(err, inner) {
|
||||
t.Fatal("PermanentError must unwrap to inner")
|
||||
}
|
||||
if err.Error() != "missing" {
|
||||
t.Fatalf("unexpected message: %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package durability
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
restate "github.com/restatedev/sdk-go"
|
||||
)
|
||||
|
||||
// RestateDurableCtx wraps a Restate Context to provide durable step execution.
|
||||
// Each Run() call is journaled by Restate — on crash/restart, completed steps
|
||||
// return cached results without re-execution.
|
||||
// Works with any Restate context type (Context, ObjectContext, WorkflowContext).
|
||||
type RestateDurableCtx struct {
|
||||
Rctx restate.Context
|
||||
}
|
||||
|
||||
func (d *RestateDurableCtx) run(name string, fn func(ctx context.Context) (any, error), opts ...restate.RunOption) (any, error) {
|
||||
allOpts := append([]restate.RunOption{restate.WithName(name)}, opts...)
|
||||
result, err := restate.Run(d.Rctx, func(rc restate.RunContext) (any, error) {
|
||||
result, err := fn(rc)
|
||||
if err != nil && !IsRetryableError(err) {
|
||||
// Default: all errors are terminal unless explicitly wrapped
|
||||
// in RetryableError by the caller.
|
||||
return result, restate.TerminalError(err)
|
||||
}
|
||||
return result, err
|
||||
}, allOpts...)
|
||||
// On replay, Restate preserves terminal-ness (restate.IsTerminalError) but
|
||||
// strips Go error types. Re-wrap as PermanentError so callers can use
|
||||
// durability.IsTerminalError without parsing error strings.
|
||||
if err != nil && restate.IsTerminalError(err) && !IsTerminalError(err) {
|
||||
return result, &PermanentError{Err: err}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *RestateDurableCtx) Run(name string, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
return d.run(name, fn)
|
||||
}
|
||||
|
||||
func (d *RestateDurableCtx) RunWithRetry(name string, policy RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
var opts []restate.RunOption
|
||||
if policy.MaxAttempts > 0 {
|
||||
opts = append(opts, restate.WithMaxRetryAttempts(uint(policy.MaxAttempts)))
|
||||
}
|
||||
if policy.InitialInterval > 0 {
|
||||
opts = append(opts, restate.WithInitialRetryInterval(policy.InitialInterval))
|
||||
}
|
||||
return d.run(name, fn, opts...)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package durability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type sample struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// fakeReplayCtx returns map[string]any from Run instead of the original type,
|
||||
// the way Restate behaves on journal replay. Verifies RunAs handles the
|
||||
// JSON round-trip transparently.
|
||||
type fakeReplayCtx struct{}
|
||||
|
||||
func (fakeReplayCtx) Run(_ string, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
v, err := fn(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Simulate JSON round-trip ala Restate replay: encode then decode into map.
|
||||
// Real Restate does this via its journal serialization.
|
||||
if s, ok := v.(sample); ok {
|
||||
return map[string]any{"name": s.Name, "count": float64(s.Count)}, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (fakeReplayCtx) RunWithRetry(name string, _ RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
return fakeReplayCtx{}.Run(name, fn)
|
||||
}
|
||||
|
||||
func TestRunAs_directPath(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
got, err := RunAs[sample](d, "step", func(_ context.Context) (sample, error) {
|
||||
return sample{Name: "alice", Count: 7}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != "alice" || got.Count != 7 {
|
||||
t.Fatalf("unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAs_replayPath(t *testing.T) {
|
||||
got, err := RunAs[sample](fakeReplayCtx{}, "step", func(_ context.Context) (sample, error) {
|
||||
return sample{Name: "bob", Count: 9}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != "bob" || got.Count != 9 {
|
||||
t.Fatalf("RunAs failed to round-trip via JSON: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAs_propagatesErrors(t *testing.T) {
|
||||
d := &DirectCtx{}
|
||||
_, err := RunAs[sample](d, "step", func(_ context.Context) (sample, error) {
|
||||
return sample{}, errors.New("boom")
|
||||
})
|
||||
if err == nil || err.Error() != "boom" {
|
||||
t.Fatalf("expected boom, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDurableCtx_prefixesStepName(t *testing.T) {
|
||||
captured := ""
|
||||
rec := &recordingCtx{onRun: func(name string) { captured = name }}
|
||||
scoped := &ScopedDurableCtx{Inner: rec, Prefix: "iter:0/"}
|
||||
_, _ = scoped.Run("foo", func(_ context.Context) (any, error) { return nil, nil })
|
||||
if captured != "iter:0/foo" {
|
||||
t.Fatalf("expected prefixed step name, got %q", captured)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCtx struct{ onRun func(string) }
|
||||
|
||||
func (r *recordingCtx) Run(name string, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
if r.onRun != nil {
|
||||
r.onRun(name)
|
||||
}
|
||||
return fn(context.Background())
|
||||
}
|
||||
|
||||
func (r *recordingCtx) RunWithRetry(name string, _ RetryPolicy, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
return r.Run(name, fn)
|
||||
}
|
||||
Reference in New Issue
Block a user