diff --git a/CHANGELOG.md b/CHANGELOG.md index a55734b..44b68d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `ActionResult::yield()` for actions to suspend themselves mid-transition pending an async response, resumed via `StateWorker::resumeWithResponse()` without re-running prior actions +- `Yieldable` marker interface for actions opting in to `ActionResult::yield()` - Initial package structure - Basic StateMachine class with state transitions - Support for named events diff --git a/README.md b/README.md index 64929e2..05b52f5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Most state machines force you into rigid patterns. StateFlow is different: - 👀 **Fully Observable** - Events fired at every step for monitoring and debugging - 🎨 **Flexible Validation** - Two-tier gates (transition-level + action-level) - 📦 **Serializable Context** - Pause, store, and resume workflows hours or days later +- ⏳ **Async Action Yielding** - Suspend a single action for external async work (webhooks, fraud checks, third-party APIs) and resume with the response; remaining actions in the same transition continue automatically - 🔧 **User-Controlled** - You define state structure, merge strategy, and lock behavior ## Perfect For @@ -137,6 +138,33 @@ $resumedWorker = $stateFlow->fromContext($pausedContext); $resumedWorker->execute(); ``` +### ⏳ Async Action Yielding + +Suspend a *single action* mid-transition to wait for an external response — remaining actions continue in the same transition once resumed: + +```php +class RunFraudCheckAction implements Action, Yieldable { + public function execute(ActionContext $context): ActionResult { + if ($context->hasYieldResponse()) { + // Second call: webhook delivered the async result + $response = $context->yieldResponse(); + return $response['outcome'] === 'approved' + ? ActionResult::continue() + : ActionResult::stop(['reason' => 'fraud_check_rejected']); + } + + // First call: dispatch external work and suspend + $this->client->startCheck($context->currentState); + return ActionResult::yield(['dispatchedAt' => time()]); + } +} + +// Webhook handler resumes the transition with the async response +$worker = $stateFlow->fromContext($persistedContext); +$worker->resumeWithResponse(['outcome' => 'approved', 'checkId' => $id]); +// Remaining actions in the transition run immediately after +``` + ### 🔒 Race Condition Prevention Built-in mutex locking, configured on the `StateFlow`: @@ -300,11 +328,12 @@ try { | Feature | StateFlow | Traditional State Machines | |---------|-----------|---------------------------| | **Granular Control** | ✅ Per-action execution & pause/resume | ❌ Must complete in one execution | +| **Async Yielding** | ✅ Single action suspends for external work, resumes with response | ❌ Must split into multiple transitions | | **Race-Safe** | ✅ Built-in mutex locking | ❌ Manual coordination required | | **Observable** | ✅ Events at every step | ❌ Limited visibility | | **Flexible State** | ✅ User-defined merge strategy | ❌ Rigid state structure | | **Lazy Config** | ✅ Load gates/actions on-demand | ❌ All configured upfront | -| **Lock Persistence** | ✅ Lock held across pauses | ❌ N/A | +| **Lock Persistence** | ✅ Lock held across pauses/yields | ❌ N/A | | **Execution Trace** | ✅ Complete audit trail | ❌ Limited history | ## Status diff --git a/docs/architecture.md b/docs/architecture.md index 02f6588..e4e78b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,6 +92,14 @@ $resumedWorker = $stateFlow->fromContext($resumedContext); $finalContext = $resumedWorker->execute(); // Continues from where it left off ``` +`pause()` suspends the whole transition; for a single action awaiting an +async response (e.g. an external API call) while keeping the rest of the +transition scoped to that one action, use `ActionResult::yield()` instead. +A `Yieldable` action holds the cursor and the lock, and is resumed via +`StateWorker::resumeWithResponse()`, which re-invokes the same action with +the response data before continuing to any remaining actions in the same +call. + ### 5. Observable Orchestration Every step emits events: diff --git a/docs/core-concepts.md b/docs/core-concepts.md index c52504e..94ad474 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -477,6 +477,7 @@ enum ExecutionState case CONTINUE; // Continue to next action case PAUSE; // Pause execution (lock persists) case STOP; // Stop execution (lock released) + case YIELD; // Suspend this action only (lock persists, cursor holds) } class ActionResult @@ -501,13 +502,18 @@ class ActionResult { return new self(ExecutionState::STOP, $newState, $metadata); } + + public static function yield(mixed $metadata = null): self + { + return new self(ExecutionState::YIELD, null, $metadata); + } } interface Action { /** * Execute the action - * Return new state or signal pause/stop + * Return new state or signal pause/stop/yield */ public function execute(ActionContext $context): ActionResult; } @@ -519,6 +525,12 @@ class ActionContext public readonly Delta $desiredDelta, public readonly TransitionContext $executionContext, ) {} + + // True when this invocation is resuming a prior ActionResult::yield() + public function hasYieldResponse(): bool; + + // The response data passed to StateWorker::resumeWithResponse() + public function yieldResponse(): mixed; } ``` @@ -580,6 +592,53 @@ class GenerateThumbnailsAction implements Action // Later, when job completes, resume the workflow ``` +**Async operation with yield:** + +```php +final class RunFraudCheckAction implements Action, Yieldable +{ + public function __construct(private FraudCheckClient $client) {} + + public function execute(ActionContext $context): ActionResult + { + // Resumed call: the webhook handler supplied the async response. + if ($context->hasYieldResponse()) { + $response = $context->yieldResponse(); // mixed, caller-defined shape + + if ($response['outcome'] === 'rejected') { + return ActionResult::stop(metadata: ['reason' => 'fraud_check_rejected']); + } + + $newState = $context->currentState->with(['fraudCheckId' => $response['checkId']]); + + return ActionResult::continue($newState); + } + + // First call: dispatch the external work and suspend. + $checkId = $this->client->startCheck($context->currentState, $context->desiredDelta); + + return ActionResult::yield(['checkId' => $checkId, 'dispatchedAt' => time()]); + } +} + +// Later, when the fraud check responds (e.g. a webhook): +$worker = $stateFlow->fromContext($persistedContext); +$worker->resumeWithResponse(['outcome' => 'approved', 'checkId' => $checkId]); +// Re-invokes RunFraudCheckAction with hasYieldResponse() === true; once it +// returns continue()/stop(), any remaining actions run immediately after, +// in the same call. +``` + +`pause()` and `yield()` both suspend a transition and hold the lock, but differ in scope: + +| | `pause()` | `yield()` | +|---|---|---| +| Scope | Suspends the whole transition | Suspends only the current action | +| Resume target | Next action in the queue | The *same* action, re-invoked | +| Resume call | `StateWorker::execute()` / `runNextAction()` | `StateWorker::resumeWithResponse()` | +| Lock | Held until completion/stop | Held until completion/stop | +| Opt-in | Any `Action` | Requires `implements Yieldable` | + **Conditional stop:** ```php diff --git a/docs/interfaces.md b/docs/interfaces.md index 5281c68..8892847 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -148,6 +148,17 @@ interface Guardable } ``` +### Yieldable + +```php +/** + * Optional capability interface. An Action implements this to opt in to + * returning ActionResult::yield() - suspending itself mid-transition without + * suspending the whole transition the way pause() does. + */ +interface Yieldable {} +``` + ## Actions ### Action @@ -177,6 +188,7 @@ class ActionResult public static function continue(?State $newState = null): self; public static function pause(?State $newState = null, mixed $metadata = null): self; public static function stop(?State $newState = null, mixed $metadata = null): self; + public static function yield(mixed $metadata = null): self; } ``` @@ -188,6 +200,7 @@ enum ExecutionState case CONTINUE; // Continue to next action case PAUSE; // Pause execution (lock persists) case STOP; // Stop execution (lock released) + case YIELD; // Suspend this action only (lock persists, cursor holds) } ``` @@ -201,6 +214,16 @@ class ActionContext public readonly Delta $desiredDelta, public readonly TransitionContext $executionContext, ) {} + + /** + * Whether this invocation is resuming from a prior ActionResult::yield(). + */ + public function hasYieldResponse(): bool; + + /** + * The response data supplied to StateWorker::resumeWithResponse(). + */ + public function yieldResponse(): mixed; } ``` @@ -383,6 +406,17 @@ class TransitionStopped extends Event } } +class TransitionYielded extends Event +{ + public function __construct( + public readonly State $currentState, + public readonly TransitionContext $context, + public readonly mixed $metadata, + ) { + parent::__construct(); + } +} + class TransitionFailed extends Event { public function __construct( @@ -701,6 +735,17 @@ class StateWorker */ public function releaseLock(): bool; + /** + * Resume a yielded transition by re-invoking the action that yielded with + * response data. The same action runs again with hasYieldResponse() true; + * once it returns continue()/stop(), any remaining actions run immediately + * after, in the same call. + * + * @throws TransitionException if the transition is not currently yielded + * @throws NonYieldableActionException if the action at the resume cursor no longer implements Yieldable + */ + public function resumeWithResponse(mixed $data): TransitionContext; + /** * Get the current TransitionContext. */ @@ -723,6 +768,7 @@ class TransitionContext implements \Serializable public function isCompleted(): bool; public function isPaused(): bool; public function isStopped(): bool; + public function isYielded(): bool; public function wasSkippedDueToLock(): bool; // Execution history (returns collections) @@ -754,6 +800,8 @@ class LockExpiredException extends \RuntimeException {} class LockLostException extends \RuntimeException {} class TransitionException extends \RuntimeException {} + +class NonYieldableActionException extends \RuntimeException {} ``` ## Helper Classes diff --git a/src/Action/ActionContext.php b/src/Action/ActionContext.php index 72c1bb2..27cba50 100644 --- a/src/Action/ActionContext.php +++ b/src/Action/ActionContext.php @@ -14,5 +14,16 @@ public function __construct( public State $currentState, public Delta $desiredDelta, public TransitionContext $executionContext, + private ?YieldResponse $yieldResponse = null, ) {} + + public function hasYieldResponse(): bool + { + return $this->yieldResponse !== null; + } + + public function yieldResponse(): mixed + { + return $this->yieldResponse?->data; + } } diff --git a/src/Action/ActionExecutionService.php b/src/Action/ActionExecutionService.php index 20a3288..406a332 100644 --- a/src/Action/ActionExecutionService.php +++ b/src/Action/ActionExecutionService.php @@ -5,9 +5,9 @@ namespace BenRowe\StateFlow\Action; use BenRowe\StateFlow\Events\EventOrchestrator; +use BenRowe\StateFlow\Exceptions\NonYieldableActionException; use BenRowe\StateFlow\Gate\GateEvaluationService; use BenRowe\StateFlow\Gate\GateResult; -use BenRowe\StateFlow\Gate\Guardable; use BenRowe\StateFlow\TransitionContext; use Throwable; @@ -33,22 +33,21 @@ public function executeAction( TransitionContext $context ): ExecutionState { // Check action guard if present - if ($action instanceof Guardable) { - $guardResult = $this->gateEvaluator->evaluateActionGuard($action, $context); + $guardResult = $this->gateEvaluator->evaluateGuardIfApplicable($action, $context); - if ($guardResult !== null) { - $context->recordActionSkip($action, $guardResult); - $this->events->actionSkipped($action, $guardResult); + if ($guardResult !== null) { + $context->recordActionSkip($action, $guardResult); + $this->events->actionSkipped($action, $guardResult); - return ExecutionState::CONTINUE; - } + return ExecutionState::CONTINUE; } - // Create action context + // Create action context, attaching any pending yield response $actionContext = new ActionContext( $context->getCurrentState(), $context->getDesiredDelta(), - $context + $context, + $context->consumePendingYieldResponseAsObject() ); // Dispatch executing event @@ -66,6 +65,13 @@ public function executeAction( throw $exception; } + // Yielding is an explicit opt-in capability; reject before anything is recorded + if ($result->executionState === ExecutionState::YIELD && !$action instanceof Yieldable) { + throw new NonYieldableActionException( + 'Action ' . $action::class . ' returned ActionResult::yield() but does not implement Yieldable' + ); + } + // Dispatch executed event $this->events->actionExecuted($action, $actionContext, $result); @@ -77,26 +83,25 @@ public function executeAction( $context->updateCurrentState($result->newState); } - // Handle pause/stop - if ($result->executionState === ExecutionState::PAUSE) { - $context->executionStatus()->markPaused($result->metadata); - $this->events->transitionPaused( - $context->getCurrentState(), - $context, - $result->metadata - ); - } elseif ($result->executionState === ExecutionState::STOP) { - $context->executionStatus()->markStopped($result->metadata); - $this->events->transitionStopped( - $context->getCurrentState(), - $context, - $result->metadata - ); - } + $this->recordSuspension($result, $context); return $result->executionState; } + /** + * Guard against resuming a yielded transition whose action no longer opts in to yielding. + * + * @throws NonYieldableActionException if the action no longer implements Yieldable + */ + public function assertYieldable(Action $action): void + { + if (!$action instanceof Yieldable) { + throw new NonYieldableActionException( + 'Action ' . $action::class . ' no longer implements Yieldable; cannot resume yielded transition' + ); + } + } + /** * Skip all actions with a given reason */ @@ -110,4 +115,21 @@ public function skipAllActions( $this->events->actionSkipped($action, $reason); } } + + /** + * Record and dispatch the event for a pause/stop/yield result. + */ + private function recordSuspension(ActionResult $result, TransitionContext $context): void + { + if ($result->executionState === ExecutionState::PAUSE) { + $context->executionStatus()->markPaused($result->metadata); + $this->events->transitionPaused($context->getCurrentState(), $context, $result->metadata); + } elseif ($result->executionState === ExecutionState::STOP) { + $context->executionStatus()->markStopped($result->metadata); + $this->events->transitionStopped($context->getCurrentState(), $context, $result->metadata); + } elseif ($result->executionState === ExecutionState::YIELD) { + $context->executionStatus()->markYielded($result->metadata); + $this->events->transitionYielded($context->getCurrentState(), $context, $result->metadata); + } + } } diff --git a/src/Action/ActionResult.php b/src/Action/ActionResult.php index 3fda7eb..46353bd 100644 --- a/src/Action/ActionResult.php +++ b/src/Action/ActionResult.php @@ -28,4 +28,9 @@ public static function stop(?State $newState = null, mixed $metadata = null): se { return new self(ExecutionState::STOP, $newState, $metadata); } + + public static function yield(mixed $metadata = null): self + { + return new self(ExecutionState::YIELD, null, $metadata); + } } diff --git a/src/Action/ExecutionState.php b/src/Action/ExecutionState.php index 2bf5b1d..4d5e20f 100644 --- a/src/Action/ExecutionState.php +++ b/src/Action/ExecutionState.php @@ -9,4 +9,10 @@ enum ExecutionState case CONTINUE; case PAUSE; case STOP; + case YIELD; + + public function isYield(): bool + { + return $this === self::YIELD; + } } diff --git a/src/Action/YieldResponse.php b/src/Action/YieldResponse.php new file mode 100644 index 0000000..0c27256 --- /dev/null +++ b/src/Action/YieldResponse.php @@ -0,0 +1,14 @@ +dispatcher->dispatch(new TransitionStopped($state, $context, $metadata)); } + public function transitionYielded(State $state, TransitionContext $context, mixed $metadata): void + { + $this->dispatcher->dispatch(new TransitionYielded($state, $context, $metadata)); + } + public function lockAcquired(string $lockKey, LockState $lockState): void { $this->dispatcher->dispatch(new LockAcquired($lockKey, $lockState)); diff --git a/src/Events/TransitionYielded.php b/src/Events/TransitionYielded.php new file mode 100644 index 0000000..d243a1a --- /dev/null +++ b/src/Events/TransitionYielded.php @@ -0,0 +1,19 @@ +actionSkips; } + /** + * Get the action index to resume execution from. + * + * Normally this is the count of executed actions. A held yield cursor is + * the exception: the yielded action must be re-invoked, not skipped. + */ + public function getResumeActionIndex(): int + { + $executedCount = $this->actionExecutions->count(); + $lastExecution = $this->actionExecutions->last(); + + if ($lastExecution !== false && $lastExecution->executionState === ExecutionState::YIELD) { + return $executedCount - 1; + } + + return $executedCount; + } + /** * Get metadata from the last PAUSE or STOP action. * diff --git a/src/ExecutionStatus.php b/src/ExecutionStatus.php index 469147c..0e04abf 100644 --- a/src/ExecutionStatus.php +++ b/src/ExecutionStatus.php @@ -18,6 +18,8 @@ final class ExecutionStatus private bool $stopped = false; + private bool $yielded = false; + private bool $skippedDueToLock = false; private mixed $metadata = null; @@ -32,6 +34,7 @@ public function markCompleted(): void $this->completed = true; $this->paused = false; $this->stopped = false; + $this->yielded = false; $this->metadata = null; } @@ -45,6 +48,7 @@ public function markPaused(mixed $metadata = null): void $this->paused = true; $this->completed = false; $this->stopped = false; + $this->yielded = false; $this->metadata = $metadata; } @@ -58,6 +62,21 @@ public function markStopped(mixed $metadata = null): void $this->stopped = true; $this->completed = false; $this->paused = false; + $this->yielded = false; + $this->metadata = $metadata; + } + + /** + * Mark the execution as yielded. + * + * @param mixed $metadata Optional metadata about why the execution was yielded + */ + public function markYielded(mixed $metadata = null): void + { + $this->yielded = true; + $this->completed = false; + $this->paused = false; + $this->stopped = false; $this->metadata = $metadata; } @@ -82,6 +101,19 @@ public function clearPauseStatus(): void } } + /** + * Clear the yielded status. + * + * Only clears if currently yielded. Does not affect stopped, paused, or completed states. + */ + public function clearYieldStatus(): void + { + if ($this->yielded) { + $this->yielded = false; + $this->metadata = null; + } + } + /** * Check if the execution is completed. */ @@ -106,6 +138,14 @@ public function isStopped(): bool return $this->stopped; } + /** + * Check if the execution is yielded. + */ + public function isYielded(): bool + { + return $this->yielded; + } + /** * Check if the execution was skipped due to lock acquisition failure. */ diff --git a/src/Gate/GateEvaluationService.php b/src/Gate/GateEvaluationService.php index 85a4342..c0e2c4f 100644 --- a/src/Gate/GateEvaluationService.php +++ b/src/Gate/GateEvaluationService.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Gate; +use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Events\EventOrchestrator; use BenRowe\StateFlow\Exceptions\InvalidGateResultException; use BenRowe\StateFlow\TransitionContext; @@ -46,6 +47,20 @@ public function evaluateTransitionGates( return GateResult::ALLOW; } + /** + * Evaluate an action's guard gate if it opts in via Guardable; null if not applicable. + */ + public function evaluateGuardIfApplicable( + Action $action, + TransitionContext $context + ): ?GateResult { + if (!$action instanceof Guardable) { + return null; + } + + return $this->evaluateActionGuard($action, $context); + } + /** * Evaluate an action guard gate */ diff --git a/src/StateFlow.php b/src/StateFlow.php index 6e4286d..6e4d14a 100644 --- a/src/StateFlow.php +++ b/src/StateFlow.php @@ -38,9 +38,7 @@ public function transition(State $currentState, Delta $delta): StateWorker return new StateWorker( $context, $this->eventDispatcher, - $this->lockContext?->provider, - $this->lockContext?->keyProvider, - $this->lockContext?->configuration + $this->lockContext ); } @@ -56,14 +54,11 @@ public function fromContext(TransitionContext $context): StateWorker $worker = new StateWorker( $context, $this->eventDispatcher, - $this->lockContext?->provider, - $this->lockContext?->keyProvider, - $this->lockContext?->configuration + $this->lockContext ); - // Resume from where we left off - count already executed actions - $executedActionCount = count($context->executionHistory()->getActionExecutions()); - $worker->setNextActionIndex($executedActionCount); + // Resume from where we left off - a held yield cursor resumes at its own index + $worker->setNextActionIndex($context->executionHistory()->getResumeActionIndex()); return $worker; } diff --git a/src/StateWorker.php b/src/StateWorker.php index d4a63eb..51ee37b 100644 --- a/src/StateWorker.php +++ b/src/StateWorker.php @@ -8,13 +8,12 @@ use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Events\EventDispatcher; use BenRowe\StateFlow\Events\EventOrchestrator; +use BenRowe\StateFlow\Exceptions\NonYieldableActionException; use BenRowe\StateFlow\Exceptions\TransitionException; use BenRowe\StateFlow\Gate\GateEvaluationService; use BenRowe\StateFlow\Gate\GateResult; -use BenRowe\StateFlow\Locking\LockConfiguration; -use BenRowe\StateFlow\Locking\LockKeyProvider; +use BenRowe\StateFlow\Locking\LockContext; use BenRowe\StateFlow\Locking\LockManager; -use BenRowe\StateFlow\Locking\LockProvider; class StateWorker { @@ -33,9 +32,7 @@ class StateWorker public function __construct( private readonly TransitionContext $context, private readonly EventDispatcher $eventDispatcher, - private readonly ?LockProvider $lockProvider = null, - private readonly ?LockKeyProvider $lockKeyProvider = null, - private readonly ?LockConfiguration $lockConfiguration = null, + private readonly ?LockContext $lockContext = null, ) { $this->configuration = $this->context->getConfiguration(); @@ -44,9 +41,9 @@ public function __construct( // Create lock manager $this->lockManager = new LockManager( - $this->lockProvider, - $this->lockKeyProvider, - $this->lockConfiguration, + $this->lockContext?->provider, + $this->lockContext?->keyProvider, + $this->lockContext?->configuration, $this->context, $this->eventOrchestrator ); @@ -85,7 +82,7 @@ public function runActions(): TransitionContext $this->executeActions(); // Mark as completed if we got through all actions - if (!$this->context->executionStatus()->isPaused() && !$this->context->executionStatus()->isStopped()) { + if ($this->isReadyToComplete()) { $this->context->executionStatus()->markCompleted(); } @@ -127,11 +124,7 @@ public function execute(): TransitionContext // SKIP_IDEMPOTENT should complete successfully (idempotent transitions are considered complete) if ($gateResult === GateResult::SKIP_IDEMPOTENT) { - $this->context->executionStatus()->markCompleted(); - $this->eventOrchestrator->transitionCompleted( - $this->context->getCurrentState(), - $this->context - ); + $this->completeTransition(); } return $this->context; @@ -139,23 +132,76 @@ public function execute(): TransitionContext // Only run actions if all gates allowed $this->executeActions(); + $this->completeTransitionIfReady(); - // Mark as completed if we got through all actions - if (!$this->context->executionStatus()->isPaused() && !$this->context->executionStatus()->isStopped()) { - $this->context->executionStatus()->markCompleted(); - $this->eventOrchestrator->transitionCompleted( - $this->context->getCurrentState(), - $this->context - ); - } + return $this->context; + } finally { + $this->releaseLockUnlessHeld(); + } + } + + /** + * Resume a yielded transition by re-invoking the action that yielded with response data. + * + * @throws TransitionException if the transition is not currently yielded + * @throws NonYieldableActionException if the action at the resume cursor no longer implements Yieldable + */ + public function resumeWithResponse(mixed $data): TransitionContext + { + if (!$this->context->executionStatus()->isYielded()) { + throw new TransitionException('Cannot resume: transition is not yielded'); + } + + $action = $this->configuration->actions->toArray()[$this->nextActionIndex]; + $this->actionExecutor->assertYieldable($action); + + $this->context->executionStatus()->clearYieldStatus(); + $this->context->setPendingYieldResponse($data); + + // Acquire lock if locking is configured + $this->lockManager->acquireLock(); + + // If transition was skipped due to lock, return early + if ($this->context->executionStatus()->wasSkippedDueToLock()) { + return $this->context; + } + + try { + $this->executeActions(); + $this->completeTransitionIfReady(); return $this->context; } finally { - // Always release lock if it was acquired (including on exception or pause/stop) - // Only keep lock if paused (scenario 9.8) - but we haven't implemented that yet - if ($this->context->lockState()->isLocked() && !$this->context->executionStatus()->isPaused()) { - $this->lockManager->releaseLock(); - } + $this->releaseLockUnlessHeld(); + } + } + + /** + * Mark the transition completed and dispatch the completion event if all actions ran through. + */ + private function completeTransitionIfReady(): void + { + if ($this->isReadyToComplete()) { + $this->completeTransition(); + } + } + + private function completeTransition(): void + { + $this->context->executionStatus()->markCompleted(); + $this->eventOrchestrator->transitionCompleted( + $this->context->getCurrentState(), + $this->context + ); + } + + /** + * Release the lock unless the transition is suspended in a way that must retain it (paused/yielded). + */ + private function releaseLockUnlessHeld(): void + { + if ($this->context->lockState()->isLocked() && !$this->isHoldingLock()) { + $this->lockManager->releaseLock(); } } @@ -168,8 +214,8 @@ private function executeActions(): void // Execute the next action $this->executeNextAction(); - // Stop if workflow is paused or stopped - if ($this->context->executionStatus()->isPaused() || $this->context->executionStatus()->isStopped()) { + // Stop if workflow is paused, stopped, or yielded + if ($this->isSuspended()) { break; } } @@ -182,6 +228,11 @@ private function executeNextAction(): void return; } + // Don't execute if workflow is yielded; resumeWithResponse() must be used instead + if ($this->context->executionStatus()->isYielded()) { + return; + } + // Check if lock is still held (detect if it was lost/expired) $this->lockManager->checkLockStillHeld(); @@ -198,6 +249,29 @@ private function executeNextAction(): void $action = $actions[$this->nextActionIndex]; $this->nextActionIndex++; - $this->actionExecutor->executeAction($action, $this->context); + $executionState = $this->actionExecutor->executeAction($action, $this->context); + + // Hold the cursor on the yielding action so resumption re-invokes it + if ($executionState->isYield()) { + $this->nextActionIndex--; + } + } + + private function isReadyToComplete(): bool + { + return !$this->isSuspended(); + } + + private function isSuspended(): bool + { + return $this->context->executionStatus()->isPaused() + || $this->context->executionStatus()->isStopped() + || $this->context->executionStatus()->isYielded(); + } + + private function isHoldingLock(): bool + { + return $this->context->executionStatus()->isPaused() + || $this->context->executionStatus()->isYielded(); } } diff --git a/src/TransitionContext.php b/src/TransitionContext.php index 36eebce..df60335 100644 --- a/src/TransitionContext.php +++ b/src/TransitionContext.php @@ -6,6 +6,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\Action\YieldResponse; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateResult; @@ -19,6 +20,10 @@ class TransitionContext private LockState $lockState; + private bool $hasPendingYieldResponse = false; + + private mixed $pendingYieldResponse = null; + public function __construct( private readonly State $initialState, private readonly Delta $desiredDelta, @@ -168,4 +173,47 @@ public function setExecutionHistory(ExecutionHistory $history): void { $this->history = $history; } + + /** + * Check if a yield response is pending consumption. + */ + public function hasPendingYieldResponse(): bool + { + return $this->hasPendingYieldResponse; + } + + /** + * Stash a yield response for the next action invocation. + * + * Transient: not part of the serialized context. + */ + public function setPendingYieldResponse(mixed $data): void + { + $this->hasPendingYieldResponse = true; + $this->pendingYieldResponse = $data; + } + + /** + * Consume the pending yield response wrapped for ActionContext, or null if none is pending. + */ + public function consumePendingYieldResponseAsObject(): ?YieldResponse + { + if (!$this->hasPendingYieldResponse) { + return null; + } + + return new YieldResponse($this->consumePendingYieldResponse()); + } + + /** + * Consume and clear the pending yield response. + */ + private function consumePendingYieldResponse(): mixed + { + $data = $this->pendingYieldResponse; + $this->hasPendingYieldResponse = false; + $this->pendingYieldResponse = null; + + return $data; + } } diff --git a/src/TransitionContextSerializer.php b/src/TransitionContextSerializer.php index 97c5f25..dded047 100644 --- a/src/TransitionContextSerializer.php +++ b/src/TransitionContextSerializer.php @@ -131,6 +131,7 @@ private function serializeExecutionStatus(TransitionContext $context): array 'completed' => $context->executionStatus()->isCompleted(), 'paused' => $context->executionStatus()->isPaused(), 'stopped' => $context->executionStatus()->isStopped(), + 'yielded' => $context->executionStatus()->isYielded(), 'skippedDueToLock' => $context->executionStatus()->wasSkippedDueToLock(), 'metadata' => $context->getStatusMetadata(), ]; @@ -262,6 +263,8 @@ private function restoreNewFormatExecutionStatus(TransitionContext $context, arr $context->executionStatus()->markPaused($metadata); } elseif ($statusData['stopped'] ?? false) { $context->executionStatus()->markStopped($metadata); + } elseif ($statusData['yielded'] ?? false) { + $context->executionStatus()->markYielded($metadata); } if ($statusData['skippedDueToLock'] ?? false) { @@ -326,6 +329,7 @@ private function restoreActionExecutionsCollection(array $decoded, StateFactory 'CONTINUE' => ExecutionState::CONTINUE, 'PAUSE' => ExecutionState::PAUSE, 'STOP' => ExecutionState::STOP, + 'YIELD' => ExecutionState::YIELD, default => ExecutionState::CONTINUE, }; $newState = null; diff --git a/tests/Integration/StateFlow/SerializationTest.php b/tests/Integration/StateFlow/SerializationTest.php index b3248a9..fd92ea7 100644 --- a/tests/Integration/StateFlow/SerializationTest.php +++ b/tests/Integration/StateFlow/SerializationTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\Action\Yieldable; use BenRowe\StateFlow\ActionFactory; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; @@ -254,6 +255,67 @@ public function testSerializeWorkflowWithStateChanges(): void ); } + /** + * Scenario: Serialize yielded workflow, then deserialize and resume with a response + * + * This simulates an action dispatching async work (e.g. an external API call), + * the transition being persisted mid-flight, and later resumed from a fresh + * request once the response is available. + */ + public function testSerializeAndResumeYieldedWorkflow(): void + { + $initialState = $this->createTestState(['status' => 'draft']); + + $action1 = new LoggingAction('PrepareDocument', $this->logger); + $action2 = new YieldingAction('RequestExternalCheck', $this->logger, ['checkId' => 'abc']); + $action3 = new LoggingAction('PublishDocument', $this->logger); + + $config = Configuration::fromArray([], [$action1, $action2, $action3]); + + // ===== PART 1: Execute workflow until yield ===== + $stateFlow1 = new StateFlow(fn () => $config); + $worker1 = $stateFlow1->transition($initialState, new ArrayDelta(['status' => 'pending'])); + $yieldedContext = $worker1->execute(); + + $this->assertTrue($yieldedContext->executionStatus()->isYielded()); + $this->assertFalse($yieldedContext->executionStatus()->isCompleted()); + $this->assertCount(2, $yieldedContext->executionHistory()->getActionExecutions()); + $this->assertEquals(['checkId' => 'abc'], $yieldedContext->getStatusMetadata()); + $this->assertNotContains('Action:PublishDocument', $this->logger->log); + + // ===== PART 2: Serialize context (e.g., save to database) ===== + $serialized = (new TransitionContextSerializer())->serialize($yieldedContext); + $this->assertJson($serialized); + + // ===== PART 3: Simulate loading from database in a new request ===== + $restoredContext = (new TransitionContextSerializer())->unserialize( + $serialized, + $this->stateFactory, + $this->actionFactory, + $this->gateFactory + ); + + $this->assertTrue($restoredContext->executionStatus()->isYielded()); + $this->assertCount(2, $restoredContext->executionHistory()->getActionExecutions()); + $this->assertEquals(['checkId' => 'abc'], $restoredContext->getStatusMetadata()); + + // ===== PART 4: Resume the workflow with the external response ===== + $stateFlow2 = new StateFlow(fn () => $config); + $worker2 = $stateFlow2->fromContext($restoredContext); + $completedContext = $worker2->resumeWithResponse(['outcome' => 'approved']); + + $this->assertTrue($completedContext->executionStatus()->isCompleted()); + $this->assertFalse($completedContext->executionStatus()->isYielded()); + $this->assertCount(4, $completedContext->executionHistory()->getActionExecutions()); + + // Verify action 3 executed after resume + $this->assertContains('Action:PublishDocument', $this->logger->log); + + // Verify the yielding action ran exactly twice (initial yield + resumed continue) + $action2Count = count(array_keys($this->logger->log, 'Action:RequestExternalCheck', true)); + $this->assertSame(2, $action2Count); + } + protected function getLogger(): ExecutionLogger { return $this->logger; @@ -315,6 +377,26 @@ public function execute(ActionContext $context): ActionResult } } +class YieldingAction implements Action, Yieldable +{ + public function __construct( + private string $name, + private ExecutionLogger $logger, + private mixed $yieldMetadata = null + ) {} + + public function execute(ActionContext $context): ActionResult + { + $this->logger->log[] = 'Action:' . $this->name; + + if ($context->hasYieldResponse()) { + return ActionResult::continue(); + } + + return ActionResult::yield($this->yieldMetadata); + } +} + class StateChangingAction implements Action { public function __construct( @@ -388,6 +470,7 @@ public function fromClassName(string $className): Action LoggingAction::class => new LoggingAction('PublishDocument', $this->logger), PausingAction::class => new PausingAction('RequestApproval', $this->logger, ['awaiting' => 'manager']), StoppingAction::class => new StoppingAction('ValidatePayment', $this->logger), + YieldingAction::class => new YieldingAction('RequestExternalCheck', $this->logger, ['checkId' => 'abc']), StateChangingAction::class => new StateChangingAction( 'ReviewAction', $this->logger, diff --git a/tests/Integration/StateFlow/WorkflowYieldTest.php b/tests/Integration/StateFlow/WorkflowYieldTest.php new file mode 100644 index 0000000..fa288d7 --- /dev/null +++ b/tests/Integration/StateFlow/WorkflowYieldTest.php @@ -0,0 +1,299 @@ +logger = new ExecutionLogger(); + } + + public function testYieldSuspendsTransitionAndHoldsCursorWithoutRunningLaterActions(): void + { + $initialState = $this->createTestState(['status' => 'draft']); + + $action1 = $this->createTestAction('Action1'); + $action2 = $this->createTestYieldableAction( + 'Action2', + ActionResult::yield(['checkId' => 'abc']), + ActionResult::continue() + ); + $action3 = $this->createTestAction('Action3'); + + $configuration = Configuration::fromArray([], [$action1, $action2, $action3]); + + $stateFlow = new StateFlow(fn () => $configuration); + $context = $stateFlow + ->transition($initialState, new ArrayDelta(['status' => 'pending'])) + ->execute(); + + $this->assertTrue($context->executionStatus()->isYielded()); + $this->assertFalse($context->executionStatus()->isCompleted()); + $this->assertCount(2, $context->executionHistory()->getActionExecutions()); + $this->assertEquals(['checkId' => 'abc'], $context->getStatusMetadata()); + + $this->assertContains('Action:Action1', $this->logger->log); + $this->assertContains('Action:Action2', $this->logger->log); + $this->assertNotContains('Action:Action3', $this->logger->log); + } + + public function testResumeWithResponseReinvokesSameActionAndRunsRemainingActionsInSameTransition(): void + { + $initialState = $this->createTestState(['status' => 'draft']); + + $action1 = $this->createTestAction('Action1'); + $action2 = $this->createTestYieldableAction( + 'Action2', + ActionResult::yield(['checkId' => 'abc']), + ActionResult::continue() + ); + $action3 = $this->createTestAction('Action3'); + + $configuration = Configuration::fromArray([], [$action1, $action2, $action3]); + + $stateFlow1 = new StateFlow(fn () => $configuration); + $yieldedContext = $stateFlow1 + ->transition($initialState, new ArrayDelta(['status' => 'pending'])) + ->execute(); + + $this->assertTrue($yieldedContext->executionStatus()->isYielded()); + + // Resume from a fresh worker, simulating a new request/process + $stateFlow2 = new StateFlow(fn () => $configuration); + $worker2 = $stateFlow2->fromContext($yieldedContext); + $completedContext = $worker2->resumeWithResponse(['outcome' => 'approved']); + + $this->assertTrue($completedContext->executionStatus()->isCompleted()); + $this->assertFalse($completedContext->executionStatus()->isYielded()); + + // Action1 ran once, Action2 ran twice (yield + resumed continue), Action3 ran once + $action1Count = count(array_keys($this->logger->log, 'Action:Action1', true)); + $action2Count = count(array_keys($this->logger->log, 'Action:Action2', true)); + $action3Count = count(array_keys($this->logger->log, 'Action:Action3', true)); + $this->assertSame(1, $action1Count); + $this->assertSame(2, $action2Count); + $this->assertSame(1, $action3Count); + + $this->assertSame($yieldedContext, $completedContext, 'Should return the same context instance'); + } + + public function testExecutionHistoryContainsBothTheYieldEntryAndTheResumedActionsFinalResult(): void + { + $initialState = $this->createTestState(['status' => 'draft']); + + $action1 = $this->createTestYieldableAction( + 'Action1', + ActionResult::yield(['checkId' => 'abc']), + ActionResult::stop(null, ['reason' => 'rejected']) + ); + + $configuration = Configuration::fromArray([], [$action1]); + + $stateFlow = new StateFlow(fn () => $configuration); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'pending'])); + $yieldedContext = $worker->execute(); + + $this->assertCount(1, $yieldedContext->executionHistory()->getActionExecutions()); + + $resumedContext = $worker->resumeWithResponse(['outcome' => 'rejected']); + + $executions = $resumedContext->executionHistory()->getActionExecutions()->toArray(); + $this->assertCount(2, $executions); + $this->assertSame(ExecutionState::YIELD, $executions[0]->executionState); + $this->assertSame(ExecutionState::STOP, $executions[1]->executionState); + $this->assertTrue($resumedContext->executionStatus()->isStopped()); + } + + public function testResumeWithResponseOnNonYieldedContextThrows(): void + { + $initialState = $this->createTestState(['status' => 'draft']); + $action1 = $this->createTestAction('Action1'); + $configuration = Configuration::fromArray([], [$action1]); + + $stateFlow = new StateFlow(fn () => $configuration); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'pending'])); + $completedContext = $worker->execute(); + + $this->assertTrue($completedContext->executionStatus()->isCompleted()); + + $this->expectException(TransitionException::class); + $this->expectExceptionMessage('Cannot resume: transition is not yielded'); + + $worker->resumeWithResponse(['outcome' => 'approved']); + } + + public function testLockIsHeldAcrossYieldAndReleasedOnceTransitionCompletes(): void + { + // The worker re-runs lock acquisition both on the initial execute() and again + // on resumeWithResponse(), mirroring how pause/resume already behaves. + $lockProvider = $this->createMock(LockProvider::class); + $lockProvider + ->expects($this->exactly(2)) + ->method('acquire') + ->with('order:123', 30) + ->willReturn(true); + $lockProvider + ->expects($this->any()) + ->method('exists') + ->with('order:123') + ->willReturn(true); + $lockProvider + ->expects($this->once()) + ->method('release') + ->with('order:123'); + + $lockKeyProvider = $this->createMock(LockKeyProvider::class); + $lockKeyProvider + ->expects($this->exactly(2)) + ->method('getLockKey') + ->willReturn('order:123'); + + $dispatchedEvents = []; + $mockDispatcher = $this->createMock(EventDispatcher::class); + $mockDispatcher + ->expects($this->any()) + ->method('dispatch') + ->willReturnCallback(function (Event $event) use (&$dispatchedEvents) { + $dispatchedEvents[] = $event; + }); + + $action = new class () implements Action, Yieldable + { + public function execute(ActionContext $context): ActionResult + { + if ($context->hasYieldResponse()) { + return ActionResult::continue(); + } + + return ActionResult::yield(['checkId' => 'abc']); + } + }; + + $lockConfig = new LockConfiguration(LockStrategy::FAIL_FAST, 30); + $lockContext = new LockContext($lockProvider, $lockKeyProvider, $lockConfig); + $config = Configuration::fromArray([], [$action]); + + $stateFlow = new StateFlow(fn () => $config, $mockDispatcher, $lockContext); + $state = $this->createTestState(['id' => '123', 'status' => 'pending']); + + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); + $yieldedContext = $worker->execute(); + + $this->assertTrue($yieldedContext->lockState()->isLocked(), 'Lock should remain held when yielded'); + $this->assertTrue($yieldedContext->executionStatus()->isYielded()); + + $lockReleasedEvents = array_filter($dispatchedEvents, fn ($e) => $e instanceof LockReleased); + $this->assertCount(0, $lockReleasedEvents, 'LockReleased event should NOT be dispatched while yielded'); + + $completedContext = $worker->resumeWithResponse(['outcome' => 'approved']); + + $this->assertTrue($completedContext->executionStatus()->isCompleted()); + + $lockReleasedEvents = array_filter($dispatchedEvents, fn ($e) => $e instanceof LockReleased); + $this->assertCount(1, $lockReleasedEvents, 'LockReleased event should be dispatched once completed'); + } + + public function testResumeWithResponseIsSkippedWhenLockCannotBeAcquiredWithSkipStrategy(): void + { + $lockProvider = $this->createMock(LockProvider::class); + $lockProvider + ->expects($this->exactly(2)) + ->method('acquire') + ->with('entity:1', 30) + ->willReturnOnConsecutiveCalls(true, false); + $lockProvider + ->expects($this->any()) + ->method('exists') + ->willReturn(true); + $lockProvider + ->expects($this->never()) + ->method('release'); + + $lockKeyProvider = $this->createMock(LockKeyProvider::class); + $lockKeyProvider + ->expects($this->any()) + ->method('getLockKey') + ->willReturn('entity:1'); + + $actionInvocationCount = 0; + $action = new class ($actionInvocationCount) implements Action, Yieldable + { + public function __construct(private int &$invocationCount) {} + + public function execute(ActionContext $context): ActionResult + { + $this->invocationCount++; + + if ($context->hasYieldResponse()) { + return ActionResult::continue(); + } + + return ActionResult::yield(); + } + }; + + $lockConfig = new LockConfiguration(LockStrategy::SKIP, 30); + $lockContext = new LockContext($lockProvider, $lockKeyProvider, $lockConfig); + $config = Configuration::fromArray([], [$action]); + + $stateFlow = new StateFlow(fn () => $config, null, $lockContext); + $state = $this->createTestState(['id' => '1', 'status' => 'draft']); + + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'pending'])); + $yieldedContext = $worker->execute(); + + $this->assertTrue($yieldedContext->executionStatus()->isYielded()); + $this->assertSame(1, $actionInvocationCount); + + $resumedContext = $worker->resumeWithResponse(['outcome' => 'approved']); + + $this->assertTrue($resumedContext->executionStatus()->wasSkippedDueToLock()); + $this->assertFalse($resumedContext->executionStatus()->isCompleted()); + $this->assertFalse($resumedContext->executionStatus()->isYielded()); + $this->assertSame(1, $actionInvocationCount, 'Action should not be re-invoked when lock cannot be acquired'); + } + + protected function getLogger(): ExecutionLogger + { + return $this->logger; + } +} diff --git a/tests/Unit/Action/ActionContextTest.php b/tests/Unit/Action/ActionContextTest.php new file mode 100644 index 0000000..77f85a0 --- /dev/null +++ b/tests/Unit/Action/ActionContextTest.php @@ -0,0 +1,53 @@ +createMock(State::class), + $this->createMock(Delta::class), + $this->createMock(TransitionContext::class), + ); + + $this->assertFalse($context->hasYieldResponse()); + $this->assertNull($context->yieldResponse()); + } + + public function testExposesYieldResponseWhenProvided(): void + { + $context = new ActionContext( + $this->createMock(State::class), + $this->createMock(Delta::class), + $this->createMock(TransitionContext::class), + new YieldResponse(['outcome' => 'approved']), + ); + + $this->assertTrue($context->hasYieldResponse()); + $this->assertSame(['outcome' => 'approved'], $context->yieldResponse()); + } + + public function testDistinguishesNullResponseDataFromNoResponse(): void + { + $context = new ActionContext( + $this->createMock(State::class), + $this->createMock(Delta::class), + $this->createMock(TransitionContext::class), + new YieldResponse(null), + ); + + $this->assertTrue($context->hasYieldResponse()); + $this->assertNull($context->yieldResponse()); + } +} diff --git a/tests/Unit/Action/ActionExecutionServiceTest.php b/tests/Unit/Action/ActionExecutionServiceTest.php index eb8da7d..d3c7ba0 100644 --- a/tests/Unit/Action/ActionExecutionServiceTest.php +++ b/tests/Unit/Action/ActionExecutionServiceTest.php @@ -6,11 +6,15 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionCollection; +use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionExecutionService; use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\Action\ExecutionState; +use BenRowe\StateFlow\Action\Yieldable; +use BenRowe\StateFlow\Action\YieldResponse; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\EventOrchestrator; +use BenRowe\StateFlow\Exceptions\NonYieldableActionException; use BenRowe\StateFlow\ExecutionStatus; use BenRowe\StateFlow\Gate\GateEvaluationService; use BenRowe\StateFlow\Gate\GateResult; @@ -22,6 +26,8 @@ interface GuardableAction extends Action, Guardable {} +interface YieldableAction extends Action, Yieldable {} + class ActionExecutionServiceTest extends TestCase { public function testExecuteActionWithContinueState(): void @@ -115,6 +121,92 @@ public function testExecuteActionWithStopState(): void $this->assertTrue($executionStatus->isStopped()); } + public function testExecuteActionWithYieldState(): void + { + $events = $this->createMock(EventOrchestrator::class); + $gateEvaluator = $this->createMock(GateEvaluationService::class); + $service = new ActionExecutionService($events, $gateEvaluator); + + $action = $this->createMock(YieldableAction::class); + $context = $this->createMock(TransitionContext::class); + $executionStatus = new ExecutionStatus(); + $state = $this->createMock(State::class); + $delta = new ArrayDelta([]); + $metadata = ['checkId' => 'abc123']; + $result = new ActionResult(ExecutionState::YIELD, null, $metadata); + + $context->method('getCurrentState')->willReturn($state); + $context->method('getDesiredDelta')->willReturn($delta); + $context->method('executionStatus')->willReturn($executionStatus); + + $action->method('execute')->willReturn($result); + + $context->expects($this->once())->method('recordActionExecution')->with($result); + + $events->expects($this->once())->method('actionExecuting'); + $events->expects($this->once())->method('actionExecuted'); + $events->expects($this->once())->method('transitionYielded')->with($state, $context, $metadata); + + $returnedState = $service->executeAction($action, $context); + + $this->assertSame(ExecutionState::YIELD, $returnedState); + $this->assertTrue($executionStatus->isYielded()); + } + + public function testExecuteActionPassesPendingYieldResponseToActionContext(): void + { + $events = $this->createMock(EventOrchestrator::class); + $gateEvaluator = $this->createMock(GateEvaluationService::class); + $service = new ActionExecutionService($events, $gateEvaluator); + + $action = $this->createMock(YieldableAction::class); + $context = $this->createMock(TransitionContext::class); + $state = $this->createMock(State::class); + $delta = new ArrayDelta([]); + $responseData = ['outcome' => 'approved']; + $result = new ActionResult(ExecutionState::CONTINUE, null); + + $context->method('getCurrentState')->willReturn($state); + $context->method('getDesiredDelta')->willReturn($delta); + $context->method('consumePendingYieldResponseAsObject')->willReturn(new YieldResponse($responseData)); + + $action->expects($this->once()) + ->method('execute') + ->with($this->callback(function (ActionContext $actionContext) use ($responseData) { + return $actionContext->hasYieldResponse() === true + && $actionContext->yieldResponse() === $responseData; + })) + ->willReturn($result); + + $service->executeAction($action, $context); + } + + public function testExecuteActionThrowsWhenNonYieldableActionYields(): void + { + $events = $this->createMock(EventOrchestrator::class); + $gateEvaluator = $this->createMock(GateEvaluationService::class); + $service = new ActionExecutionService($events, $gateEvaluator); + + $action = $this->createMock(Action::class); + $context = $this->createMock(TransitionContext::class); + $state = $this->createMock(State::class); + $delta = new ArrayDelta([]); + $result = new ActionResult(ExecutionState::YIELD, null, ['checkId' => 'abc123']); + + $context->method('getCurrentState')->willReturn($state); + $context->method('getDesiredDelta')->willReturn($delta); + + $action->method('execute')->willReturn($result); + + $context->expects($this->never())->method('recordActionExecution'); + $events->expects($this->never())->method('actionExecuted'); + $events->expects($this->never())->method('transitionYielded'); + + $this->expectException(NonYieldableActionException::class); + + $service->executeAction($action, $context); + } + public function testExecuteActionUpdatesStateWhenActionReturnsNewState(): void { $events = $this->createMock(EventOrchestrator::class); @@ -148,7 +240,7 @@ public function testExecuteActionSkipsWhenGuardDenies(): void $action = $this->createMock(GuardableAction::class); $context = $this->createMock(TransitionContext::class); - $gateEvaluator->method('evaluateActionGuard')->with($action, $context)->willReturn(GateResult::DENY); + $gateEvaluator->method('evaluateGuardIfApplicable')->with($action, $context)->willReturn(GateResult::DENY); $context->expects($this->once())->method('recordActionSkip')->with($action, GateResult::DENY); $events->expects($this->once())->method('actionSkipped')->with($action, GateResult::DENY); diff --git a/tests/Unit/Action/ActionResultTest.php b/tests/Unit/Action/ActionResultTest.php index c486ec0..c4707e8 100644 --- a/tests/Unit/Action/ActionResultTest.php +++ b/tests/Unit/Action/ActionResultTest.php @@ -49,6 +49,19 @@ public function testStop(?State $state, ?array $metadata): void $this->assertSame($metadata, $result->metadata); } + /** + * @param ?mixed[] $metadata + */ + #[DataProvider('provideYieldData')] + public function testYield(?array $metadata): void + { + $result = ActionResult::yield($metadata); + $this->assertInstanceOf(ActionResult::class, $result); + $this->assertSame(ExecutionState::YIELD, $result->executionState); + $this->assertNull($result->newState); + $this->assertSame($metadata, $result->metadata); + } + /** * @return array */ @@ -85,6 +98,17 @@ public static function provideStopData(): array ]; } + /** + * @return array + */ + public static function provideYieldData(): array + { + return [ + 'no metadata' => [null], + 'with metadata' => [['foo' => 'bar']], + ]; + } + private static function mockState(): State { $mock = (new MockGenerator()) diff --git a/tests/Unit/Action/ExecutionStateTest.php b/tests/Unit/Action/ExecutionStateTest.php new file mode 100644 index 0000000..d634dc7 --- /dev/null +++ b/tests/Unit/Action/ExecutionStateTest.php @@ -0,0 +1,31 @@ +assertSame($expected, $state->isYield()); + } + + /** + * @return array + */ + public static function provideIsYield(): array + { + return [ + 'YIELD returns true' => [ExecutionState::YIELD, true], + 'CONTINUE returns false' => [ExecutionState::CONTINUE, false], + 'PAUSE returns false' => [ExecutionState::PAUSE, false], + 'STOP returns false' => [ExecutionState::STOP, false], + ]; + } +} diff --git a/tests/Unit/Events/EventOrchestratorTest.php b/tests/Unit/Events/EventOrchestratorTest.php index 3f9afd2..4a5fc69 100644 --- a/tests/Unit/Events/EventOrchestratorTest.php +++ b/tests/Unit/Events/EventOrchestratorTest.php @@ -25,6 +25,7 @@ use BenRowe\StateFlow\Events\TransitionFailed; use BenRowe\StateFlow\Events\TransitionPaused; use BenRowe\StateFlow\Events\TransitionStopped; +use BenRowe\StateFlow\Events\TransitionYielded; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; use BenRowe\StateFlow\Gate\GateResult; @@ -236,6 +237,26 @@ public function testTransitionStoppedDispatchesCorrectEvent(): void $orchestrator->transitionStopped($state, $context, $metadata); } + public function testTransitionYieldedDispatchesCorrectEvent(): void + { + $state = $this->createMock(State::class); + $context = $this->createMock(TransitionContext::class); + $metadata = ['checkId' => 'abc123']; + $dispatcher = $this->createMock(EventDispatcher::class); + + $dispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function ($event) use ($state, $context, $metadata) { + return $event instanceof TransitionYielded + && $event->currentState === $state + && $event->context === $context + && $event->metadata === $metadata; + })); + + $orchestrator = new EventOrchestrator($dispatcher); + $orchestrator->transitionYielded($state, $context, $metadata); + } + public function testLockAcquiredDispatchesCorrectEvent(): void { $lockState = new LockState('test-key', 1234567.89, 30); diff --git a/tests/Unit/Events/TransitionYieldedTest.php b/tests/Unit/Events/TransitionYieldedTest.php new file mode 100644 index 0000000..3e974aa --- /dev/null +++ b/tests/Unit/Events/TransitionYieldedTest.php @@ -0,0 +1,26 @@ +createMock(State::class); + $context = $this->createMock(TransitionContext::class); + $metadata = ['checkId' => 'abc123']; + + $event = new TransitionYielded($currentState, $context, $metadata); + + $this->assertSame($currentState, $event->currentState); + $this->assertSame($context, $event->context); + $this->assertSame($metadata, $event->metadata); + } +} diff --git a/tests/Unit/ExecutionStatusTest.php b/tests/Unit/ExecutionStatusTest.php index 846595a..423594b 100644 --- a/tests/Unit/ExecutionStatusTest.php +++ b/tests/Unit/ExecutionStatusTest.php @@ -16,6 +16,7 @@ public function testDefaultState(): void $this->assertFalse($status->isCompleted()); $this->assertFalse($status->isPaused()); $this->assertFalse($status->isStopped()); + $this->assertFalse($status->isYielded()); $this->assertFalse($status->wasSkippedDueToLock()); $this->assertNull($status->getMetadata()); } @@ -28,6 +29,85 @@ public function testMarkCompleted(): void $this->assertTrue($status->isCompleted()); $this->assertFalse($status->isPaused()); $this->assertFalse($status->isStopped()); + $this->assertFalse($status->isYielded()); + } + + public function testMarkYieldedWithoutMetadata(): void + { + $status = new ExecutionStatus(); + $status->markYielded(); + + $this->assertFalse($status->isCompleted()); + $this->assertFalse($status->isPaused()); + $this->assertFalse($status->isStopped()); + $this->assertTrue($status->isYielded()); + $this->assertNull($status->getMetadata()); + } + + public function testMarkYieldedWithMetadata(): void + { + $status = new ExecutionStatus(); + $metadata = ['checkId' => 'abc123']; + + $status->markYielded($metadata); + + $this->assertTrue($status->isYielded()); + $this->assertSame($metadata, $status->getMetadata()); + } + + public function testMarkCompletedClearsYielded(): void + { + $status = new ExecutionStatus(); + $status->markYielded(['checkId' => 'abc123']); + + $status->markCompleted(); + + $this->assertFalse($status->isYielded()); + } + + public function testMarkPausedClearsYielded(): void + { + $status = new ExecutionStatus(); + $status->markYielded(['checkId' => 'abc123']); + + $status->markPaused(); + + $this->assertFalse($status->isYielded()); + } + + public function testMarkStoppedClearsYielded(): void + { + $status = new ExecutionStatus(); + $status->markYielded(['checkId' => 'abc123']); + + $status->markStopped(); + + $this->assertFalse($status->isYielded()); + } + + public function testClearYieldStatusWhenYielded(): void + { + $status = new ExecutionStatus(); + $status->markYielded(['checkId' => 'abc123']); + + $status->clearYieldStatus(); + + $this->assertFalse($status->isYielded()); + $this->assertFalse($status->isCompleted()); + $this->assertFalse($status->isPaused()); + $this->assertFalse($status->isStopped()); + $this->assertNull($status->getMetadata()); + } + + public function testClearYieldStatusWhenNotYieldedDoesNothing(): void + { + $status = new ExecutionStatus(); + $status->markStopped(['error' => 'test']); + + $status->clearYieldStatus(); + + $this->assertTrue($status->isStopped()); + $this->assertSame(['error' => 'test'], $status->getMetadata()); } public function testMarkPausedWithoutMetadata(): void @@ -167,15 +247,24 @@ public function testOnlyOneStatusCanBeTrueAtATime(): void $this->assertTrue($status->isCompleted()); $this->assertFalse($status->isPaused()); $this->assertFalse($status->isStopped()); + $this->assertFalse($status->isYielded()); $status->markPaused(); $this->assertFalse($status->isCompleted()); $this->assertTrue($status->isPaused()); $this->assertFalse($status->isStopped()); + $this->assertFalse($status->isYielded()); $status->markStopped(); $this->assertFalse($status->isCompleted()); $this->assertFalse($status->isPaused()); $this->assertTrue($status->isStopped()); + $this->assertFalse($status->isYielded()); + + $status->markYielded(); + $this->assertFalse($status->isCompleted()); + $this->assertFalse($status->isPaused()); + $this->assertFalse($status->isStopped()); + $this->assertTrue($status->isYielded()); } } diff --git a/tests/Unit/Gate/GateEvaluationServiceTest.php b/tests/Unit/Gate/GateEvaluationServiceTest.php index 371e743..e13e404 100644 --- a/tests/Unit/Gate/GateEvaluationServiceTest.php +++ b/tests/Unit/Gate/GateEvaluationServiceTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Unit\Gate; +use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\EventOrchestrator; use BenRowe\StateFlow\Exceptions\InvalidGateResultException; @@ -16,6 +17,8 @@ use BenRowe\StateFlow\TransitionContext; use PHPUnit\Framework\TestCase; +interface GuardableTestAction extends Action, Guardable {} + class GateEvaluationServiceTest extends TestCase { public function testEvaluateTransitionGatesReturnsAllowWhenAllGatesPass(): void @@ -154,6 +157,46 @@ public function testEvaluateActionGuardReturnsGateResultWhenDenied(): void $this->assertSame(GateResult::DENY, $result); } + public function testEvaluateGuardIfApplicableReturnsNullWhenActionIsNotGuardable(): void + { + $eventOrchestrator = $this->createMock(EventOrchestrator::class); + $service = new GateEvaluationService($eventOrchestrator); + + $action = $this->createMock(Action::class); + $context = $this->createMock(TransitionContext::class); + + $eventOrchestrator->expects($this->never())->method('gateEvaluating'); + $context->expects($this->never())->method('recordGateEvaluation'); + + $result = $service->evaluateGuardIfApplicable($action, $context); + + $this->assertNull($result); + } + + public function testEvaluateGuardIfApplicableDelegatesWhenActionIsGuardable(): void + { + $eventOrchestrator = $this->createMock(EventOrchestrator::class); + $service = new GateEvaluationService($eventOrchestrator); + + $gate = $this->createMock(Gate::class); + $gate->method('evaluate')->willReturn(GateResult::DENY); + + $action = $this->createMock(GuardableTestAction::class); + $action->method('gate')->willReturn($gate); + + $context = $this->createMock(TransitionContext::class); + $state = $this->createMock(State::class); + $delta = new ArrayDelta([]); + + $context->method('getCurrentState')->willReturn($state); + $context->method('getDesiredDelta')->willReturn($delta); + $context->expects($this->once())->method('recordGateEvaluation'); + + $result = $service->evaluateGuardIfApplicable($action, $context); + + $this->assertSame(GateResult::DENY, $result); + } + public function testEvaluateThrowsInvalidGateResultExceptionOnTypeError(): void { $eventOrchestrator = $this->createMock(EventOrchestrator::class); diff --git a/tests/Unit/StateFlowTest.php b/tests/Unit/StateFlowTest.php index daf16ae..19a859f 100644 --- a/tests/Unit/StateFlowTest.php +++ b/tests/Unit/StateFlowTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Unit; +use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\State; @@ -52,6 +53,35 @@ public function testItCanBeInitialisedWithExistingContext(): void $this->assertSame($config, $worker->getContext()->getConfiguration()); } + public function testFromContextResumesYieldedActionAtItsOwnIndexRatherThanPastIt(): void + { + $yieldingAction = $this->createTestYieldableAction( + 'YieldingAction', + ActionResult::yield(['checkId' => 'abc']), + ActionResult::continue() + ); + $secondAction = $this->createTestAction('SecondAction'); + $config = Configuration::fromArray([], [$yieldingAction, $secondAction]); + + $stateFlow = new StateFlow(fn () => $config); + $state = $this->createTestState(['foo' => 'bar']); + $context = $stateFlow->transition($state, new ArrayDelta([]))->execute(); + + $this->assertTrue($context->executionStatus()->isYielded()); + $this->assertSame(['Action:YieldingAction'], $this->logger->log); + + // Simulate resuming from a freshly-deserialized context in a new request + $resumedStateFlow = new StateFlow(fn () => $config); + $worker = $resumedStateFlow->fromContext($context); + $resumedContext = $worker->resumeWithResponse(['outcome' => 'approved']); + + $this->assertTrue($resumedContext->executionStatus()->isCompleted()); + $this->assertSame( + ['Action:YieldingAction', 'Action:YieldingAction', 'Action:SecondAction'], + $this->logger->log + ); + } + protected function getLogger(): ExecutionLogger { return $this->logger; diff --git a/tests/Unit/StateWorkerTest.php b/tests/Unit/StateWorkerTest.php index 25eed20..d0d6f44 100644 --- a/tests/Unit/StateWorkerTest.php +++ b/tests/Unit/StateWorkerTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\Action\Yieldable; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Events\ActionExecuted; @@ -17,6 +18,7 @@ use BenRowe\StateFlow\Events\NullEventDispatcher; use BenRowe\StateFlow\Events\TransitionCompleted; use BenRowe\StateFlow\Exceptions\InvalidGateResultException; +use BenRowe\StateFlow\Exceptions\NonYieldableActionException; use BenRowe\StateFlow\Exceptions\TransitionException; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; @@ -25,6 +27,7 @@ use BenRowe\StateFlow\GateEvaluation; use BenRowe\StateFlow\StateWorker; use BenRowe\StateFlow\Tests\Utils\ExecutionLogger; +use BenRowe\StateFlow\Tests\Utils\Traits\CreatesTestActions; use BenRowe\StateFlow\Tests\Utils\Traits\CreatesTestGates; use BenRowe\StateFlow\Tests\Utils\Traits\CreatesTestStates; use BenRowe\StateFlow\TransitionContext; @@ -36,6 +39,7 @@ */ class StateWorkerTest extends TestCase { + use CreatesTestActions; use CreatesTestGates; use CreatesTestStates; @@ -416,6 +420,92 @@ public function execute(ActionContext $context): ActionResult $this->assertTrue($context->executionStatus()->isStopped()); } + public function testExecuteActionsHoldsCursorWhenActionReturnsYield(): void + { + $yieldAction = $this->createTestYieldableAction('Action1', ActionResult::yield(['checkId' => 'abc123'])); + $secondAction = $this->createStubAction('SecondAction'); + + $config = Configuration::fromArray([], [$yieldAction, $secondAction]); + $state = $this->createTestState(['status' => 'draft']); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); + + $mockDispatcher = new NullEventDispatcher(); + + $worker = new StateWorker($context, $mockDispatcher); + $worker->runActions(); + + // Only first action should execute, second should not + $this->assertCount(1, $context->executionHistory()->getActionExecutions()); + $this->assertTrue($context->executionStatus()->isYielded()); + + // Cursor must not have advanced past the yielding action; a stray runNextAction() + // call must not silently re-dispatch the action without response data + $worker->runNextAction(); + $this->assertCount(1, $context->executionHistory()->getActionExecutions(), 'Yielded action must not re-execute via runNextAction()'); + } + + public function testResumeWithResponseThrowsWhenNotYielded(): void + { + $action = $this->createStubAction('TestAction'); + + $config = Configuration::fromArray([], [$action]); + $state = $this->createTestState(['status' => 'draft']); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); + + $worker = new StateWorker($context, new NullEventDispatcher()); + + $this->expectException(TransitionException::class); + $worker->resumeWithResponse(['outcome' => 'approved']); + } + + public function testResumeWithResponseThrowsWhenActionNoLongerYieldable(): void + { + // Action is no longer Yieldable (e.g. configuration drift since the original yield) + $action = $this->createStubAction('TestAction'); + + $config = Configuration::fromArray([], [$action]); + $state = $this->createTestState(['status' => 'draft']); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); + $context->executionStatus()->markYielded(['checkId' => 'abc123']); + + $worker = new StateWorker($context, new NullEventDispatcher()); + + $this->expectException(NonYieldableActionException::class); + $worker->resumeWithResponse(['outcome' => 'approved']); + } + + public function testResumeWithResponseReinvokesSameActionAndRunsRemainingActions(): void + { + $responseData = ['outcome' => 'approved']; + $yieldAction = $this->createTestYieldableAction( + 'Action1', + ActionResult::yield(['checkId' => 'abc123']), + ActionResult::continue() + ); + $secondAction = $this->createTestAction('SecondAction'); + + $config = Configuration::fromArray([], [$yieldAction, $secondAction]); + $state = $this->createTestState(['status' => 'draft']); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); + + $worker = new StateWorker($context, new NullEventDispatcher()); + $worker->runActions(); + + $this->assertTrue($context->executionStatus()->isYielded()); + $this->assertCount(1, $context->executionHistory()->getActionExecutions()); + + $resumedContext = $worker->resumeWithResponse($responseData); + + // Same action re-invoked (now recorded with CONTINUE) plus the second action + $this->assertCount(3, $resumedContext->executionHistory()->getActionExecutions()); + $this->assertSame( + ['Action:Action1', 'Action:Action1', 'Action:SecondAction'], + $this->logger->log + ); + $this->assertTrue($resumedContext->executionStatus()->isCompleted()); + $this->assertFalse($resumedContext->executionStatus()->isYielded()); + } + public function testEvaluateGateThrowsInvalidGateResultExceptionOnTypeError(): void { // Create a gate that returns invalid type (not GateResult) diff --git a/tests/Unit/TransitionContextTest.php b/tests/Unit/TransitionContextTest.php index 1ccbdb1..327275e 100644 --- a/tests/Unit/TransitionContextTest.php +++ b/tests/Unit/TransitionContextTest.php @@ -6,6 +6,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\Action\YieldResponse; use BenRowe\StateFlow\ActionSkip; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; @@ -415,6 +416,41 @@ public function testGetStatusMetadataReturnsNullWhenMetadataIsNull(): void $this->assertNull($context->getStatusMetadata()); } + public function testHasPendingYieldResponseDefaultsToFalse(): void + { + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); + + $this->assertFalse($context->hasPendingYieldResponse()); + } + + public function testSetPendingYieldResponseMarksItPending(): void + { + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); + + $context->setPendingYieldResponse(['outcome' => 'approved']); + + $this->assertTrue($context->hasPendingYieldResponse()); + } + + public function testConsumePendingYieldResponseAsObjectReturnsNullWhenNothingPending(): void + { + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); + + $this->assertNull($context->consumePendingYieldResponseAsObject()); + } + + public function testConsumePendingYieldResponseAsObjectReturnsWrappedDataAndClears(): void + { + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); + $context->setPendingYieldResponse(['outcome' => 'approved']); + + $response = $context->consumePendingYieldResponseAsObject(); + + $this->assertEquals(new YieldResponse(['outcome' => 'approved']), $response); + $this->assertFalse($context->hasPendingYieldResponse()); + $this->assertNull($context->consumePendingYieldResponseAsObject()); + } + protected function getLogger(): ExecutionLogger { return $this->logger; diff --git a/tests/Utils/Traits/CreatesTestActions.php b/tests/Utils/Traits/CreatesTestActions.php index d2227fd..f0d9960 100644 --- a/tests/Utils/Traits/CreatesTestActions.php +++ b/tests/Utils/Traits/CreatesTestActions.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\Action\Yieldable; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; use BenRowe\StateFlow\Gate\GateResult; @@ -83,6 +84,37 @@ public function execute(ActionContext $context): ActionResult }; } + private function createTestYieldableAction( + string $name, + ActionResult $firstResult, + ?ActionResult $resumedResult = null + ): Action { + $logger = $this->getLogger(); + + return new class ($name, $firstResult, $resumedResult, $logger) implements Action, Yieldable + { + public function __construct( + private string $name, + private ActionResult $firstResult, + private ?ActionResult $resumedResult, + private ExecutionLogger $logger + ) {} + + public function execute(ActionContext $context): ActionResult + { + $this->logger->log[] = 'Action:' . $this->name; + + if ($context->hasYieldResponse()) { + assert($this->resumedResult !== null); + + return $this->resumedResult; + } + + return $this->firstResult; + } + }; + } + private function createTestGuardedAction(string $name, GateResult $gateResult): Action { $logger = $this->getLogger();