Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e55ced6
Add ActionResult::yield() and ExecutionState::YIELD
claude Jun 30, 2026
05dfd21
Add Yieldable marker interface and NonYieldableActionException
claude Jun 30, 2026
faa3f5c
Add ActionContext yield-response accessors
claude Jun 30, 2026
3eabb5e
Add yielded status to ExecutionStatus
claude Jun 30, 2026
162aa43
Add pending yield response slot to TransitionContext
claude Jun 30, 2026
9a504a0
Add TransitionYielded event and orchestrator method
claude Jun 30, 2026
597f0af
Handle ActionResult::yield() in ActionExecutionService
claude Jun 30, 2026
0e7881b
Add StateWorker cursor hold and resumeWithResponse() for yield
claude Jun 30, 2026
9009337
Resume yielded transitions at the correct action index
claude Jun 30, 2026
5f476d6
Serialize and restore yielded transition status
claude Jun 30, 2026
b411b8f
Add integration tests covering yield/resume acceptance criteria
claude Jun 30, 2026
ffe54d9
Document ActionResult::yield() across interfaces, core-concepts, arch…
claude Jun 30, 2026
378b475
Consolidate ActionContext yield-response params into YieldResponse va…
claude Jun 30, 2026
28174fb
Move resume-action-index calculation from StateFlow into ExecutionHis…
claude Jun 30, 2026
446b51b
Move action guard evaluation from ActionExecutionService into GateEva…
claude Jun 30, 2026
ada59ce
Reduce StateWorker coupling by accepting LockContext and deduplicatin…
claude Jun 30, 2026
5ad2ed2
Make TransitionContext::consumePendingYieldResponse private to reduce…
claude Jun 30, 2026
6ae6b0f
Cover resumeWithResponse() lock-skip path with SKIP strategy test
claude Jun 30, 2026
a066fbb
Replace @phpstan-impure workaround with ExecutionState::isYield() helper
claude Jun 30, 2026
0167692
Add async yield/resume to README feature list and Key Features
claude Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 60 additions & 1 deletion docs/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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;
}
```

Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
```

Expand All @@ -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)
}
```

Expand All @@ -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;
}
```

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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)
Expand Down Expand Up @@ -754,6 +800,8 @@ class LockExpiredException extends \RuntimeException {}
class LockLostException extends \RuntimeException {}

class TransitionException extends \RuntimeException {}

class NonYieldableActionException extends \RuntimeException {}
```

## Helper Classes
Expand Down
11 changes: 11 additions & 0 deletions src/Action/ActionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading
Loading