RcmEmailAutomation / sa-orchestration /includes /class-sa-fluent-state.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
7.7 kB
<?php
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;
}
}