76c01e44df
對齊 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
237 lines
7.0 KiB
PHP
237 lines
7.0 KiB
PHP
<?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;
|
||
}
|
||
}
|