Spaces:
Sleeping
Sleeping
| defined( 'ABSPATH' ) || exit; | |
| /** | |
| * Fluent state wrapper β the single canonical entry point for orchestration- | |
| * driven task movement. | |
| * | |
| * Why this exists: | |
| * Direct ORM stage updates ($task->stage_id = X; $task->save();) bypass | |
| * Fluent's lifecycle behavior. Specifically, they do NOT fire: | |
| * - do_action('fluent_boards/task_stage_updated', $task, $oldStageId) | |
| * which feeds: | |
| * - ActivityHandler@logTaskStageUpdatedActivity (the audit log) | |
| * - NotificationHandler@changeStageNotification (emails) | |
| * - StageChangedTrigger (FluentCRM automations) | |
| * They also leave 'status' and 'last_completed_at' stale relative to the | |
| * stage's default_task_status setting. | |
| * | |
| * The default path (move_task) keeps the system in sync. The gated path | |
| * (move_task_silent) preserves the same state correctness but suppresses the | |
| * action β for context repair / migration / latent-checks that should not | |
| * generate notifications. Both paths use Fluent's own Task::close() and | |
| * Task::reopen() primitives so the canonical timestamp behavior is honored. | |
| * | |
| * Direct ORM ($task->save() with no helpers) remains available as an | |
| * implicit escape hatch for ad-hoc surgery; nothing in this class blocks it. | |
| * The choice is at the call site. | |
| * | |
| * Architectural rule recorded: | |
| * - Protocol comment is canonical for lifecycle (Proposed/Approved/ | |
| * In Progress/Blocked/Complete/Accepted). | |
| * - Fluent stage_id + status are projections of that lifecycle. | |
| * - This wrapper keeps the projection true. | |
| */ | |
| class SA_Orch_Fluent_State { | |
| /** | |
| * Move a task to a new stage and fire Fluent's lifecycle behavior. | |
| * | |
| * Behavior: | |
| * - reads old stage_id | |
| * - sets new stage_id, saves | |
| * - aligns status + last_completed_at via Task::close() / Task::reopen() | |
| * based on the new stage's default_task_status setting | |
| * - fires do_action('fluent_boards/task_stage_updated', $task, $oldStageId) | |
| * | |
| * Idempotent: if old == new stage_id, no-op returns true. | |
| * Returns false if task or stage not found. | |
| */ | |
| public static function move_task( int $task_id, int $new_stage_id, array $opts = [] ): bool { | |
| return self::do_move( $task_id, $new_stage_id, true /* fire events */ ); | |
| } | |
| /** | |
| * Same as move_task() but does NOT fire 'fluent_boards/task_stage_updated'. | |
| * | |
| * Use cases (developer choice β explicitly opt-in): | |
| * - Backfilling drifted state without spamming notifications | |
| * - Repair operations during migration | |
| * - Latent-check passes outside the project board concept | |
| * | |
| * State correctness (status, last_completed_at) is still maintained. | |
| * Only the side-channel notifications/automations are suppressed. | |
| */ | |
| public static function move_task_silent( int $task_id, int $new_stage_id, array $opts = [] ): bool { | |
| return self::do_move( $task_id, $new_stage_id, false /* suppress events */ ); | |
| } | |
| /** | |
| * Internal mover. Single code path; the only difference between move_task | |
| * and move_task_silent is whether the action fires at the end. | |
| * | |
| * When $fire_events = true (default move_task path), the Description | |
| * Contract gates from Board #27 also apply: | |
| * - Pre-Work gate: blocks Open β In Progress when description is empty | |
| * - Completion gate: blocks In Progress β Complete when description | |
| * lacks acceptance language ('accept', 'criteria', 'done when', | |
| * 'pass when' β case-insensitive substring; deterministic, no NLP). | |
| * The silent variant explicitly bypasses both gates β it's the gated | |
| * developer-choice path for context repair / migration. | |
| */ | |
| private static function do_move( int $task_id, int $new_stage_id, bool $fire_events ): bool { | |
| $task = \FluentBoards\App\Models\Task::find( $task_id ); | |
| if ( ! $task ) { | |
| return false; | |
| } | |
| $old_stage_id = (int) $task->stage_id; | |
| if ( $old_stage_id === $new_stage_id ) { | |
| // Idempotent no-op. Stage is already where requested. | |
| return true; | |
| } | |
| $stage = \FluentBoards\App\Models\Stage::find( $new_stage_id ); | |
| if ( ! $stage ) { | |
| return false; | |
| } | |
| // Board #27 β Description Contract gates. Apply only on default path. | |
| if ( $fire_events && ! self::description_contract_passes( $task, $new_stage_id ) ) { | |
| return false; | |
| } | |
| // Move the stage first so subsequent close()/reopen() observers see | |
| // the new stage as the source of truth. | |
| $task->stage_id = $new_stage_id; | |
| $task->save(); | |
| // Align status + last_completed_at to the new stage's contract. | |
| // Task::close() and Task::reopen() are Fluent's own primitives and | |
| // handle the timestamp invariants correctly. | |
| $default_status = $stage->defaultTaskStatus(); | |
| if ( $default_status === 'closed' && $task->status !== 'closed' ) { | |
| $task->close(); | |
| } elseif ( $default_status === 'open' && $task->status === 'closed' ) { | |
| $task->reopen(); | |
| } | |
| if ( $fire_events ) { | |
| do_action( 'fluent_boards/task_stage_updated', $task, $old_stage_id ); | |
| } | |
| return true; | |
| } | |
| /** | |
| * Board #27 β Description Contract gates. | |
| * | |
| * Returns true if the transition is allowed. Returns false (with | |
| * error_log entry explaining why) if the gate blocks. | |
| * | |
| * Stage IDs (board 16): | |
| * 131 = Open / Approved / [QUEUE] | |
| * 132 = In Progress | |
| * 133 = Completed | |
| * | |
| * Gate logic: | |
| * - Moving INTO 132 (In Progress) β Pre-Work gate: description must be non-empty | |
| * - Moving INTO 133 (Completed) β Completion gate: description must contain | |
| * one of 'accept', 'criteria', 'done when', | |
| * 'pass when' (case-insensitive substring) | |
| * - Other stage moves β no gate | |
| * | |
| * Both gates are bypassed by move_task_silent() (the gated escape hatch from #19). | |
| */ | |
| private static function description_contract_passes( $task, int $new_stage_id ): bool { | |
| $description = isset( $task->description ) ? trim( (string) $task->description ) : ''; | |
| if ( $new_stage_id === 132 ) { | |
| if ( $description === '' ) { | |
| error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to In Progress. Use move_task_silent() to bypass for repair/migration." ); | |
| return false; | |
| } | |
| return true; | |
| } | |
| if ( $new_stage_id === 133 ) { | |
| if ( $description === '' ) { | |
| error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} has empty description; cannot move to Complete. Use move_task_silent() to bypass." ); | |
| return false; | |
| } | |
| $lower = strtolower( $description ); | |
| $patterns = [ 'accept', 'criteria', 'done when', 'pass when' ]; | |
| $hit = false; | |
| foreach ( $patterns as $p ) { | |
| if ( strpos( $lower, $p ) !== false ) { $hit = true; break; } | |
| } | |
| if ( ! $hit ) { | |
| error_log( "[SA-Orch #27] move_task blocked: task #{$task->id} description lacks acceptance language (one of: 'accept', 'criteria', 'done when', 'pass when'); cannot move to Complete. Use move_task_silent() to bypass." ); | |
| return false; | |
| } | |
| return true; | |
| } | |
| // Other stage moves: no gate. | |
| return true; | |
| } | |
| } | |