Files
2meet-data-optimizer/includes/advisor/class-tmdo-fsm-automator.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

237 lines
7.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_FSM_Automator — Opt-in automatic execution of FSM_Advisor PROMOTE
* recommendations (v2.5.0 M13).
*
* Default OFF. When admin enables, runs daily at 04:30 UTC. For each module
* - Calls TMDO_FSM_Advisor::advise()
* - Only auto-promotes when ALL 4 conditions true:
* 1. wpdo_automator_enabled = 1
* 2. module not in wpdo_automator_blacklist
* 3. TMDO_Health_Cron::get_last_run() critical_count = 0 in last 7 days
* (cool-off — system is healthy)
* 4. ≥ 24h since this module's last automated promotion
* - Destructive transitions (cutover→cleanup, cleanup→complete) are
* NEVER automated — admin must manually invoke.
*
* Audit trail: every automator action writes wp_wpdo_audit op='automator_promoted'
* + fires action `wpdo/automator_promoted` for downstream subscribers.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Automator — stateless static API.
*/
class TMDO_FSM_Automator {
public const OPT_ENABLED = 'wpdo_automator_enabled';
public const OPT_BLACKLIST = 'wpdo_automator_blacklist';
public const OPT_LAST_ACTION = 'wpdo_automator_last_action';
/** Cool-off window after critical health alert. */
private const COOL_OFF_DAYS = 7;
/** Minimum interval between automated promotions of the same module. */
private const MIN_INTERVAL_HOURS = 24;
/** Transitions that are never auto-executed (destructive). */
private const FORBIDDEN_TRANSITIONS = array(
array( 'verify', 'cutover' ), // cutover starts reading from custom — needs manual sign-off.
array( 'cutover', 'cleanup' ), // cleanup purges wp_*meta — irreversible without snapshot.
array( 'cleanup', 'complete' ), // complete = no fallback path.
);
/**
* Cron handler.
*
* @return array {ok:bool, executed:int, skipped:int, errors:array}
*/
public static function run(): array {
$result = array(
'ok' => true,
'executed' => 0,
'skipped' => 0,
'errors' => array(),
'actions' => array(),
);
// Pre-flight: enabled?
if ( ! self::is_enabled() ) {
$result['ok'] = false;
$result['errors'][] = 'automator disabled';
return $result;
}
// Pre-flight: cool-off?
if ( ! self::cool_off_clear() ) {
$result['ok'] = false;
$result['errors'][] = 'cool-off active (critical health in last ' . self::COOL_OFF_DAYS . ' days)';
return $result;
}
// Iterate modules.
if ( ! class_exists( 'TMDO_Feature_Flags' ) || ! class_exists( 'TMDO_FSM_Advisor' ) ) {
$result['ok'] = false;
$result['errors'][] = 'dependencies missing';
return $result;
}
$blacklist = (array) get_option( self::OPT_BLACKLIST, array() );
$last_actions = (array) get_option( self::OPT_LAST_ACTION, array() );
foreach ( TMDO_Feature_Flags::all() as $module => $current_state ) {
// Blacklisted?
if ( in_array( $module, $blacklist, true ) ) {
++$result['skipped'];
continue;
}
// Get advisor recommendation.
$advice = TMDO_FSM_Advisor::advise( $module );
if ( 'PROMOTE' !== ( $advice['action'] ?? '' ) || 'info' !== ( $advice['level'] ?? '' ) ) {
++$result['skipped'];
continue;
}
$next_state = (string) ( $advice['next_state'] ?? '' );
if ( '' === $next_state ) {
++$result['skipped'];
continue;
}
// Forbidden destructive transition?
if ( self::is_forbidden_transition( $current_state, $next_state ) ) {
++$result['skipped'];
continue;
}
// Recent action?
if ( ! self::interval_clear( $module, $last_actions ) ) {
++$result['skipped'];
continue;
}
// All checks passed — execute.
$set_result = TMDO_Feature_Flags::set( $module, $next_state );
if ( true === $set_result ) {
++$result['executed'];
$result['actions'][] = array(
'module' => $module,
'from' => $current_state,
'to' => $next_state,
);
$last_actions[ $module ] = gmdate( 'Y-m-d H:i:s' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'automator_promoted',
array(
'module' => $module,
'from' => $current_state,
'to' => $next_state,
)
);
}
do_action( 'wpdo/automator_promoted', $module, $current_state, $next_state );
} else {
$result['errors'][] = "{$module}: " . ( $set_result instanceof \WP_Error ? $set_result->get_error_code() : 'unknown' );
}
}
update_option( self::OPT_LAST_ACTION, $last_actions, false );
return $result;
}
// ─── settings accessors ─────────────────────────────────────────────
/**
* Return whether the automator is enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return '1' === (string) get_option( self::OPT_ENABLED, '0' );
}
/**
* Return the list of blacklisted module slugs.
*
* @return array
*/
public static function blacklist(): array {
return (array) get_option( self::OPT_BLACKLIST, array() );
}
/**
* Return the map of module → last automated action timestamp.
*
* @return array
*/
public static function last_actions(): array {
return (array) get_option( self::OPT_LAST_ACTION, array() );
}
// ─── private helpers ───────────────────────────────────────────────
/**
* Cool-off: false when there's been a critical health alert in last N days.
*
* @return bool true = OK to act, false = blocked.
*/
private static function cool_off_clear(): bool {
if ( ! class_exists( 'TMDO_Health_Cron' ) ) {
return true; // No data — assume OK.
}
$last = TMDO_Health_Cron::get_last_run();
if ( ! is_array( $last ) ) {
return true;
}
$crit = (int) ( $last['critical_count'] ?? 0 );
if ( 0 === $crit ) {
return true;
}
$ts = isset( $last['ran_at'] ) ? strtotime( (string) $last['ran_at'] . ' UTC' ) : 0;
if ( $ts <= 0 ) {
return true;
}
// Critical alert exists; cool-off if within window.
return ( time() - $ts ) > ( self::COOL_OFF_DAYS * DAY_IN_SECONDS );
}
/**
* Per-module interval: ≥ 24h since last automated action on this module.
*
* @param string $module Module slug.
* @param array $last_actions Map of module → timestamp.
* @return bool true = OK to act.
*/
private static function interval_clear( string $module, array $last_actions ): bool {
$last = (string) ( $last_actions[ $module ] ?? '' );
if ( '' === $last ) {
return true;
}
$ts = strtotime( $last . ' UTC' );
if ( $ts <= 0 ) {
return true;
}
return ( time() - $ts ) >= ( self::MIN_INTERVAL_HOURS * HOUR_IN_SECONDS );
}
/**
* Check if (from, to) is in FORBIDDEN_TRANSITIONS.
*
* @param string $from Current state.
* @param string $to Target state.
* @return bool
*/
private static function is_forbidden_transition( string $from, string $to ): bool {
foreach ( self::FORBIDDEN_TRANSITIONS as $pair ) {
if ( $pair[0] === $from && $pair[1] === $to ) {
return true;
}
}
return false;
}
}