| 1 |
<?php |
| 2 |
/** |
| 3 |
* Maps the raw signals of an FSI time-slice into the normalized MCP status |
| 4 |
* enum (spec 042 FR-002/FR-003/FR-004, research.md §4, data-model.md). |
| 5 |
* |
| 6 |
* PURE: takes already-fetched inputs (the SSE events returned by the latest |
| 7 |
* `import` slice, the SessionData row, and the log tail) and returns a plain |
| 8 |
* array. No I/O, so it is unit-testable in isolation (T005). |
| 9 |
* |
| 10 |
* The FSI pipeline (Constitution V exception taxonomy) emits exactly these |
| 11 |
* terminal / again signals, so the mapping is total: |
| 12 |
* - action:'complete' → complete (carries `results` summary) |
| 13 |
* - action:'error' retry:true → needs_retry (RetryableErrorException) |
| 14 |
* - action:'error' retry:false → failed (Non-retirable / Unknown) |
| 15 |
* - action:'continue' / a slice ran with no terminal event → running |
| 16 |
* - empty session row (7-day cleanup) or unknown handle → expired |
| 17 |
* |
| 18 |
* @package Templately\Modules\FullSiteImport\Abilities\Support |
| 19 |
*/ |
| 20 |
|
| 21 |
namespace Templately\Modules\FullSiteImport\Abilities\Support; |
| 22 |
|
| 23 |
class FsiStatusNormalizer { |
| 24 |
|
| 25 |
const STATUS_RUNNING = 'running'; |
| 26 |
const STATUS_NEEDS_RETRY = 'needs_retry'; |
| 27 |
const STATUS_FAILED = 'failed'; |
| 28 |
const STATUS_COMPLETE = 'complete'; |
| 29 |
const STATUS_EXPIRED = 'expired'; |
| 30 |
|
| 31 |
/** |
| 32 |
* @param array $sse_events Parsed SSE event objects from the latest slice. |
| 33 |
* @param mixed $session_data The SessionData row for the handle (array), or empty. |
| 34 |
* @param array $log_tail Recent log lines (for surfacing `log`). |
| 35 |
* @return array { |
| 36 |
* status, progress (0-100), phase, message?, summary? |
| 37 |
* } |
| 38 |
*/ |
| 39 |
public static function normalize( array $sse_events, $session_data, array $log_tail = [] ): array { |
| 40 |
// Unknown / expired handle: no persisted session to speak of. |
| 41 |
if ( empty( $session_data ) || ! is_array( $session_data ) ) { |
| 42 |
return [ |
| 43 |
'status' => self::STATUS_EXPIRED, |
| 44 |
'progress' => 0, |
| 45 |
'phase' => '', |
| 46 |
]; |
| 47 |
} |
| 48 |
|
| 49 |
$terminal = self::find_terminal_event( $sse_events ); |
| 50 |
$progress = self::derive_progress( $sse_events, $session_data ); |
| 51 |
$phase = self::derive_phase( $sse_events, $session_data ); |
| 52 |
|
| 53 |
if ( $terminal && 'complete' === $terminal['action'] ) { |
| 54 |
return [ |
| 55 |
'status' => self::STATUS_COMPLETE, |
| 56 |
'progress' => 100, |
| 57 |
'phase' => 'finalizer', |
| 58 |
'summary' => $terminal['results'] ?? [], |
| 59 |
]; |
| 60 |
} |
| 61 |
|
| 62 |
if ( $terminal && 'error' === $terminal['action'] ) { |
| 63 |
$retry = ! empty( $terminal['retry'] ); |
| 64 |
return [ |
| 65 |
'status' => $retry ? self::STATUS_NEEDS_RETRY : self::STATUS_FAILED, |
| 66 |
'progress' => $progress, |
| 67 |
'phase' => $phase, |
| 68 |
// Message + code only — never a stack trace (FR-006; the trace is |
| 69 |
// already stripped server-side in RunsImport::import()). |
| 70 |
'message' => isset( $terminal['message'] ) ? (string) $terminal['message'] : '', |
| 71 |
]; |
| 72 |
} |
| 73 |
|
| 74 |
// A slice ran (or is between chunks) with no terminal event: still running. |
| 75 |
return [ |
| 76 |
'status' => self::STATUS_RUNNING, |
| 77 |
'progress' => $progress, |
| 78 |
'phase' => $phase, |
| 79 |
]; |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* The last complete/error event in the slice, if any (terminal wins over |
| 84 |
* intermediate `continue`/`updateLog` events). |
| 85 |
* |
| 86 |
* @param array $sse_events |
| 87 |
* @return array|null |
| 88 |
*/ |
| 89 |
private static function find_terminal_event( array $sse_events ): ?array { |
| 90 |
$found = null; |
| 91 |
foreach ( $sse_events as $event ) { |
| 92 |
if ( ! is_array( $event ) ) { |
| 93 |
continue; |
| 94 |
} |
| 95 |
$action = $event['action'] ?? ''; |
| 96 |
if ( 'complete' === $action || 'error' === $action ) { |
| 97 |
$found = $event; // keep the last one |
| 98 |
} |
| 99 |
} |
| 100 |
return $found; |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Coarse 0-100 progress: prefer an explicit `progress` from the latest |
| 105 |
* updateLog event; otherwise approximate from completed session steps. |
| 106 |
* |
| 107 |
* @param array $sse_events |
| 108 |
* @param array $session_data |
| 109 |
* @return int |
| 110 |
*/ |
| 111 |
private static function derive_progress( array $sse_events, array $session_data ): int { |
| 112 |
$explicit = null; |
| 113 |
foreach ( $sse_events as $event ) { |
| 114 |
if ( is_array( $event ) && isset( $event['progress'] ) && is_numeric( $event['progress'] ) ) { |
| 115 |
$explicit = (int) $event['progress']; |
| 116 |
} |
| 117 |
} |
| 118 |
if ( null !== $explicit ) { |
| 119 |
return max( 0, min( 100, $explicit ) ); |
| 120 |
} |
| 121 |
|
| 122 |
// Fallback: fraction of known early setup steps completed. |
| 123 |
$steps = [ 'create_log_dir', 'check_writing_permission', 'download_zip' ]; |
| 124 |
$progress = $session_data['progress'] ?? []; |
| 125 |
$done = 0; |
| 126 |
foreach ( $steps as $step ) { |
| 127 |
if ( ! empty( $progress[ $step ] ) ) { |
| 128 |
$done++; |
| 129 |
} |
| 130 |
} |
| 131 |
// Cap the setup-only fallback well below 100 — content import follows. |
| 132 |
return (int) round( ( $done / count( $steps ) ) * 40 ); |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Current phase label from the latest informative event, else the last |
| 137 |
* completed session step. |
| 138 |
* |
| 139 |
* @param array $sse_events |
| 140 |
* @param array $session_data |
| 141 |
* @return string |
| 142 |
*/ |
| 143 |
private static function derive_phase( array $sse_events, array $session_data ): string { |
| 144 |
$phase = ''; |
| 145 |
foreach ( $sse_events as $event ) { |
| 146 |
if ( ! is_array( $event ) ) { |
| 147 |
continue; |
| 148 |
} |
| 149 |
if ( ! empty( $event['info'] ) ) { |
| 150 |
$phase = (string) $event['info']; |
| 151 |
} elseif ( ! empty( $event['type'] ) && ! in_array( $event['type'], [ 'eventLog', 'updateLog', 'start' ], true ) ) { |
| 152 |
$phase = (string) $event['type']; |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
if ( '' === $phase ) { |
| 157 |
$progress = $session_data['progress'] ?? []; |
| 158 |
if ( is_array( $progress ) && ! empty( $progress ) ) { |
| 159 |
$keys = array_keys( array_filter( $progress ) ); |
| 160 |
$phase = end( $keys ) ?: ''; |
| 161 |
} |
| 162 |
} |
| 163 |
|
| 164 |
return $phase; |
| 165 |
} |
| 166 |
} |
| 167 |
|