# WorkflowEngine

> Seed-driven automation engine. Rules live in `workflow_rules` table, seeded from pack JSON.
> Admin UI for rule management is Phase 3.1.
> **Critical:** Contains loop guard (R5) — read fully before implementing.

---

## Migrations

### `workflow_rules`
```php
Schema::create('workflow_rules', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();
    $table->string('name');
    $table->string('trigger_event');        // 'lead.created' | 'lead.updated' | 'deal.stage_changed' | 'scheduled'
    $table->string('entity_type');          // 'lead' | 'deal'
    $table->json('conditions')->nullable(); // [{field, operator, value}, ...]
    $table->json('actions');                // [{type, config}, ...]
    // Scheduled-trigger fields — nullable, only used when trigger_event = 'scheduled'
    $table->string('scheduled_cron')->nullable();   // standard cron e.g. '0 9 * * *' (9am daily)
    $table->timestamp('last_tick_at')->nullable();  // last time workflow:tick fired this rule
    $table->boolean('is_active')->default(true);
    $table->boolean('is_async')->default(false);        // true = dispatch queued job instead of running inline
    $table->boolean('is_system')->default(false);
    $table->string('feature_pack_slug')->nullable();
    $table->timestamps();
    $table->index(['trigger_event', 'is_active']);  // fast lookup for workflow:tick scans
});
```

### `workflow_runs`
```php
Schema::create('workflow_runs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('workflow_rule_id')->constrained('workflow_rules')->cascadeOnDelete();
    $table->string('entity_type');
    $table->unsignedBigInteger('entity_id');
    $table->string('status');               // 'running'|'completed'|'failed'
    $table->text('error')->nullable();
    $table->string('context_id')->nullable(); // X-Request-ID for trace correlation
    $table->timestamp('started_at')->nullable();
    $table->timestamp('finished_at')->nullable();
    $table->timestamps();
    $table->index(['entity_type', 'entity_id']);
    $table->index('context_id');
});
```

---

## `WorkflowDispatcher` — With Loop Guard (R5)

```php
// CoreApp/app/Services/WorkflowEngine/WorkflowDispatcher.php
namespace CoreApp\Services\WorkflowEngine;

use CoreApp\Models\WorkflowEngine\{WorkflowRule, WorkflowRun};
use Illuminate\Database\Eloquent\Model;

class WorkflowDispatcher
{
    /** Loop guard: tracks in-flight dispatches. Static = per-request scope. */
    private static array $running = [];

    public function dispatch(string $event, Model $entity, array $context = []): void
    {
        $key = $event . ':' . $entity->getMorphClass() . ':' . $entity->getKey();

        if (isset(self::$running[$key])) {
            return; // already processing this entity+event — UpdateField recursion guard
        }

        self::$running[$key] = true;

        try {
            $rules = WorkflowRule::where('trigger_event', $event)
                ->where('entity_type', $entity->getMorphClass())
                ->where('is_active', true)
                ->get();

            foreach ($rules as $rule) {
                $this->executeRule($rule, $entity, $context);
            }
        } catch (\Throwable $e) {
            // Rule-level failures are caught inside executeRule(). This outer catch covers
            // infrastructure failures (DB down, malformed JSON in conditions column, etc.)
            \Illuminate\Support\Facades\Log::error('WorkflowDispatcher::dispatch failed', [
                'event'     => $event,
                'entity'    => $entity->getMorphClass() . ':' . $entity->getKey(),
                'exception' => $e->getMessage(),
            ]);
        } finally {
            unset(self::$running[$key]); // always release, even on exception
        }
    }

    private function executeRule(WorkflowRule $rule, Model $entity, array $context): void
    {
        // Evaluate conditions before creating a run record — skip silently if not matched
        if (!empty($rule->conditions) && !app(ConditionEvaluator::class)->passes($entity, $rule->conditions)) {
            return;
        }

        // Async rules are dispatched to the queue — does not block the HTTP response
        if ($rule->is_async) {
            dispatch(new ExecuteWorkflowRuleJob($rule->id, $entity->getMorphClass(), $entity->getKey(), $context));
            return;
        }

        $run = WorkflowRun::create([
            'workflow_rule_id' => $rule->id,
            'entity_type'      => $entity->getMorphClass(),
            'entity_id'        => $entity->getKey(),
            'status'           => 'running',
            'context_id'       => request()->header('X-Request-ID') ?? (string) \Illuminate\Support\Str::uuid(),
            'started_at'       => now(),
        ]);

        try {
            foreach ($rule->actions as $actionConfig) {
                $action = $this->resolveAction($actionConfig['type']);
                $action->execute($entity, $actionConfig['config'] ?? [], $context);
            }
            $run->update(['status' => 'completed', 'finished_at' => now()]);
        } catch (\Throwable $e) {
            $run->update(['status' => 'failed', 'error' => $e->getMessage(), 'finished_at' => now()]);
            report($e);
        }
    }

    private function resolveAction(string $type): WorkflowActionInterface
    {
        return match($type) {
            'assign_to_user'    => new Actions\AssignToUser(),
            'send_notification' => new Actions\SendNotification(),
            'create_task'       => new Actions\CreateTask(),
            'move_stage'        => new Actions\MoveStage(),
            'update_field'      => new Actions\UpdateField(),
            'compute_score'     => new Actions\ComputeScore(),
            'fire_webhook'      => new Actions\FireWebhook(),
            default             => throw new \InvalidArgumentException("Unknown action type: {$type}"),
        };
    }
}
```

Bind in `CoreAppServiceProvider::register()`:
```php
$this->app->singleton(WorkflowDispatcher::class);
```

---

## Trait: `FiresWorkflowEvents`

```php
// CoreApp/app/Traits/FiresWorkflowEvents.php
namespace CoreApp\Traits;

use CoreApp\Services\WorkflowEngine\WorkflowDispatcher;

trait FiresWorkflowEvents
{
    public static function bootFiresWorkflowEvents(): void
    {
        static::created(fn ($model) => app(WorkflowDispatcher::class)
            ->dispatch(static::workflowEntityType() . '.created', $model));

        static::updated(fn ($model) => app(WorkflowDispatcher::class)
            ->dispatch(static::workflowEntityType() . '.updated', $model));
    }

    /** Override in model to set the event prefix. E.g. return 'lead'; */
    protected static function workflowEntityType(): string
    {
        return static::class;
    }
}
```

In `Lead` model: `protected static function workflowEntityType(): string { return 'lead'; }`
In `Deal` model: `protected static function workflowEntityType(): string { return 'deal'; }`

---

## 7 Action Classes

### Interface
```php
interface WorkflowActionInterface
{
    public function execute(Model $entity, array $config, array $context): void;
}
```

### `AssignToUser` — Round-Robin

> **Requires Redis:** `crm_round_robin_idx` is stored in Laravel's cache. This key must be
> shared across all PHP processes. `CACHE_DRIVER=redis` is a hard prerequisite — APCu is
> per-process and will produce duplicate assignments on horizontal deployments.

```php
class AssignToUser implements WorkflowActionInterface
{
    public function execute(Model $entity, array $config, array $context): void
    {
        $role  = $config['role'] ?? 'sales';
        $users = \App\Models\User::role($role)->pluck('id');
        if ($users->isEmpty()) return;

        // Cache key MUST be tenant-scoped — without tenant prefix all tenants share one counter
        $cacheKey = 'crm_round_robin_idx:' . tenant('id');
        $idx      = (int) cache()->get($cacheKey, 0);
        $userId   = $users[$idx % $users->count()];
        cache()->put($cacheKey, $idx + 1, now()->addDays(30));

        $entity->update(['owner_id' => $userId]);

        // Log to ActivityEngine (R1 observability)
        if (method_exists($entity, 'logActivity')) {
            $entity->logActivity('auto_assign', [
                'assigned_to'        => $userId,
                'round_robin_index'  => $idx,
                'role'               => $role,
            ]);
        }
    }
}
```

### `SendNotification`
```php
// Sends a notification to a user or role
public function execute(Model $entity, array $config, array $context): void
{
    $user = \App\Models\User::find($config['user_id'] ?? null);
    if (!$user) return;
    $user->notify(new \CoreApp\Notifications\WorkflowNotification($entity, $config));
}
```

### `CreateTask`
```php
public function execute(Model $entity, array $config, array $context): void
{
    \Productivity\Task\Models\Task::create([
        'title'         => $config['title'] ?? 'Follow up',
        'taskable_type' => $entity->getMorphClass(),
        'taskable_id'   => $entity->getKey(),
        'due_date'      => now()->addDays($config['due_days'] ?? 1),
        'assigned_to'   => $entity->owner_id,
    ]);
}
```

### `MoveStage`
```php
public function execute(Model $entity, array $config, array $context): void
{
    $stage = \CoreApp\Models\PipelineEngine\PipelineStage
        ::where('slug', $config['stage_slug'])->firstOrFail();
    $entity->moveToStage($stage, 'workflow:' . ($config['reason'] ?? ''));
}
```

### `UpdateField`
```php
// Safe because WorkflowDispatcher loop guard prevents recursion
public function execute(Model $entity, array $config, array $context): void
{
    if (method_exists($entity, 'setCustomField')) {
        $entity->setCustomField($config['field'], $config['value']);
    } else {
        $entity->update([$config['field'] => $config['value']]);
    }
}
```

### `ComputeScore`
```php
// Calls AI seam — returns null via NullAiHook in v1
public function execute(Model $entity, array $config, array $context): void
{
    $score = app(\CoreApp\Contracts\AiHookInterface::class)->computeScore($entity);
    if ($score !== null && isset($entity->score)) {
        $entity->update(['score' => $score]);
    }
}
```

### `FireWebhook`
```php
public function execute(Model $entity, array $config, array $context): void
{
    dispatch(new \CoreApp\Jobs\FireWebhookJob($config['url'], $entity->toArray()));
}
```

---

## AI Seam

```php
// CoreApp/app/Contracts/AiHookInterface.php
interface AiHookInterface
{
    public function computeScore(\Illuminate\Database\Eloquent\Model $entity): ?int;
    public function suggestNextAction(\Illuminate\Database\Eloquent\Model $entity): ?string;
}

// CoreApp/app/Services/WorkflowEngine/NullAiHook.php
class NullAiHook implements AiHookInterface
{
    public function computeScore(Model $entity): ?int { return null; }
    public function suggestNextAction(Model $entity): ?string { return null; }
}
```

Bind in `CoreAppServiceProvider::register()`:
```php
$this->app->bind(AiHookInterface::class, NullAiHook::class);
```

Replace `NullAiHook` with LLM implementation in Phase 3.8 without changing any other code.

---

## `ConditionEvaluator`

```php
// CoreApp/app/Services/WorkflowEngine/ConditionEvaluator.php
class ConditionEvaluator
{
    /** Returns true if all conditions pass (AND logic). Empty conditions = always pass. */
    public function passes(Model $entity, array $conditions): bool
    {
        foreach ($conditions as $c) {
            $actual = array_key_exists($c['field'], $entity->getAttributes())
                ? $entity->getAttribute($c['field'])
                : (method_exists($entity, 'getCustomField') ? $entity->getCustomField($c['field']) : null);

            if (!$this->evaluate($actual, $c['operator'], $c['value'])) {
                return false;
            }
        }
        return true;
    }

    private function evaluate(mixed $actual, string $operator, mixed $expected): bool
    {
        // Relative-time tokens — used by scheduled rules. Resolved before comparison.
        // Supported: 'now', 'now-7d', 'now+1h', 'now-30m'
        $expected = $this->resolveRelativeTime($expected);
        $actual   = $this->resolveRelativeTime($actual);

        return match($operator) {
            '='         => $actual == $expected,
            '!='        => $actual != $expected,
            '>'         => $actual > $expected,
            '>='        => $actual >= $expected,
            '<'         => $actual < $expected,
            '<='        => $actual <= $expected,
            'in'        => in_array($actual, (array) $expected),
            'not_in'    => !in_array($actual, (array) $expected),
            'contains'  => str_contains((string) $actual, (string) $expected),
            'is_null'   => $actual === null,
            'is_not_null' => $actual !== null,
            default     => throw new \InvalidArgumentException("Unknown operator: {$operator}"),
        };
    }

    /** Resolve 'now', 'now-7d', 'now+1h', 'now-30m' to a Carbon instance. Pass-through otherwise. */
    private function resolveRelativeTime(mixed $value): mixed
    {
        if (!is_string($value) || !str_starts_with($value, 'now')) return $value;
        if ($value === 'now') return now();
        if (preg_match('/^now([+-])(\d+)([dhm])$/', $value, $m)) {
            $sign   = $m[1] === '-' ? -1 : 1;
            $amount = (int) $m[2] * $sign;
            return match ($m[3]) {
                'd' => now()->addDays($amount),
                'h' => now()->addHours($amount),
                'm' => now()->addMinutes($amount),
            };
        }
        return $value;
    }
}
```

Bind in `CoreAppServiceProvider::register()`:
```php
$this->app->singleton(ConditionEvaluator::class);
```

---

## Failed Run Retry Command

Add `workflow:retry-failed` Artisan command to re-dispatch workflow runs stuck in `failed` status:

```php
// CoreApp/app/Console/Commands/RetryFailedWorkflowRunsCommand.php
// Signature: workflow:retry-failed {--minutes=60 : Only retry runs failed within this window}
// Finds WorkflowRun::where('status', 'failed')->where('finished_at', '>=', now()->subMinutes($minutes))
// Re-dispatches each as ExecuteWorkflowRuleJob
// Schedule: daily, or trigger manually after incident investigation
```

Register in `CoreAppServiceProvider`. Run via `php artisan workflow:retry-failed --minutes=1440`.

---

## Scheduled Trigger — `workflow:tick`

Time-based rules (e.g. "if lead has no activity for 7 days, notify owner") run via the `workflow:tick`
command, fired every 5 minutes by Laravel's scheduler. The command:

1. Loads all active rules where `trigger_event = 'scheduled'`.
2. Filters by `scheduled_cron` — only rules whose cron expression matches the current minute fire.
3. For each matching rule, runs the rule's `entity_type` query through `ConditionEvaluator`.
4. For every entity that passes, dispatches the rule via `WorkflowDispatcher` exactly as event-driven
   rules — same loop guard, same audit log, same async path.

```php
// CoreApp/app/Console/Commands/WorkflowTickCommand.php
namespace CoreApp\Console\Commands;

use CoreApp\Models\WorkflowEngine\WorkflowRule;
use CoreApp\Services\WorkflowEngine\{WorkflowDispatcher, ConditionEvaluator};
use CoreApp\Services\EntityEngine\EntityRegistry;
use Cron\CronExpression;
use Illuminate\Console\Command;

class WorkflowTickCommand extends Command
{
    protected $signature   = 'workflow:tick';
    protected $description = 'Fire scheduled workflow rules whose cron expression matches the current minute.';

    public function handle(
        WorkflowDispatcher $dispatcher,
        ConditionEvaluator $evaluator,
        EntityRegistry     $registry,
    ): int {
        $now   = now();
        $rules = WorkflowRule::where('trigger_event', 'scheduled')
            ->where('is_active', true)
            ->whereNotNull('scheduled_cron')
            ->get();

        foreach ($rules as $rule) {
            // CronExpression handles all standard cron syntax including '*/15', '0 9 * * 1-5', etc.
            if (!CronExpression::factory($rule->scheduled_cron)->isDue($now)) {
                continue;
            }

            $modelClass = $registry->modelClass($rule->entity_type);
            // Hydrate matching entities — keep result set bounded to avoid runaway scans
            $modelClass::query()
                ->when(true, fn ($q) => $q->limit(config('crm.workflow_tick_batch', 500)))
                ->get()
                ->filter(fn ($entity) => $evaluator->passes($entity, $rule->conditions ?? []))
                ->each(fn ($entity) => $dispatcher->dispatch('scheduled', $entity, ['rule_id' => $rule->id]));

            $rule->update(['last_tick_at' => $now]);
        }

        return self::SUCCESS;
    }
}
```

Register the command in `CoreAppServiceProvider::boot()`:
```php
if ($this->app->runningInConsole()) {
    $this->commands([\CoreApp\Console\Commands\WorkflowTickCommand::class]);
}
```

Schedule in `app/Console/Kernel.php` (project root, not CoreApp):
```php
protected function schedule(Schedule $schedule): void
{
    // Run every 5 minutes — finer granularity than 1 minute is overkill for a CRM and increases load
    $schedule->command('workflow:tick')->everyFiveMinutes()->withoutOverlapping();
}
```

> **Multi-tenant note:** the scheduler runs in the *central* context. To dispatch tenant-aware ticks,
> wrap the command in `tenancy()->runForMultiple($tenants, fn () => Artisan::call('workflow:tick'))`
> inside the schedule closure. Reference `setup.md` for the exact pattern used by other tenant-bound
> scheduled jobs in the project.

### When to use `scheduled` rules vs event rules

| Trigger | Use when | Example |
|---------|----------|---------|
| `lead.created` / `*.updated` / `*.stage_changed` | Reaction to a user action | "Auto-assign new lead to a counselor" |
| `scheduled` | Reaction to *the passage of time* | "If lead has no activity for 7 days, notify owner"; "At 9am every weekday, summarize stale deals to manager" |

**Don't** use scheduled rules for things that should be event-driven — events are cheaper and immediate.
Reserve scheduled rules for time-relative conditions (`updated_at < now() - 7 days`).

### Example pack JSON for a scheduled rule

```json
{
  "slug": "stale_lead_reminder",
  "name": "Notify owner of stale leads",
  "trigger_event": "scheduled",
  "scheduled_cron": "0 9 * * 1-5",
  "entity_type": "lead",
  "conditions": [
    { "field": "converted_at", "operator": "is_null",  "value": null },
    { "field": "updated_at",   "operator": "<",        "value": "now-7d" }
  ],
  "actions": [
    { "type": "send_notification", "config": { "user_id": "{{owner_id}}", "template": "stale_lead" } }
  ]
}
```

> The `now-7d` literal is recognised by `ConditionEvaluator` as a relative-time token —
> v1 supports `now`, `now-Nd`, `now+Nd`, `now-Nh`, `now+Nh`. Add to operator list in `ConditionEvaluator`.

---

## Supported Trigger Events

| Event | Fired by |
|-------|----------|
| `lead.created` | `FiresWorkflowEvents::bootFiresWorkflowEvents` static::created |
| `lead.updated` | `FiresWorkflowEvents::bootFiresWorkflowEvents` static::updated |
| `deal.created` | same, on Deal model |
| `deal.updated` | same, on Deal model |
| `deal.stage_changed` | fired explicitly by `HasPipeline::moveToStage()` — see pipeline-engine.md |
| `scheduled` | fired by `workflow:tick` Artisan command (cron expression match) |
