Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.78% covered (warning)
81.78%
211 / 258
71.43% covered (warning)
71.43%
25 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentCopyController
81.78% covered (warning)
81.78%
211 / 258
71.43% covered (warning)
71.43%
25 / 35
171.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 jobDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 statePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 engineConfig
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 engine
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 lock
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 jobToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 controllerLockPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 advance
46.51% covered (danger)
46.51%
20 / 43
0.00% covered (danger)
0.00%
0 / 1
49.43
 resetLocked
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 withdrawCompatChannel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 status
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 isInProgress
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 isStale
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 runSetup
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 holdsLockOrFail
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 runCopyBurst
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 runCore
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 runFinalize
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 ownStepStillRunning
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 swapAlreadyCompleted
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 finishLocked
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 callVerb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verbFailed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 verbMessage
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 fail
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 writeRunning
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 writeSuccess
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 envelope
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 phaseStatus
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 jobExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadState
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 defaultState
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 newLockToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveState
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
7.73
1<?php
2/**
3 * Orchestrates the stepped, chunked content-deploy (staging -> production) over many short requests.
4 *
5 * @package NewfoldLabs\WP\Module\Staging
6 */
7
8namespace NewfoldLabs\WP\Module\Staging\Copy;
9
10use NewfoldLabs\WP\Module\Staging\Staging;
11use NewfoldLabs\WP\Module\Staging\StagingCommandLock;
12
13/**
14 * Drives one deploy-files content copy through five phases -- setup -> copy -> core -> finalize ->
15 * done -- doing at most one bounded unit of work per advance() call, so a poll loop (the REST advance
16 * route or the WP-cron fallback) can finish an arbitrarily large deploy without any single request
17 * running long.
18 *
19 * The three bash bookends run through Staging's existing command path (token handshake, env, escaping):
20 *   - setup    -> `deploy_content_setup`    (sweeps stale -new/-old; nothing else)
21 *   - core     -> `deploy_content_core`     (WordPress core download + php-ai-client, deliberately
22 *                                            after the copy so a long copy never leaves production
23 *                                            running new core against old content)
24 *   - finalize -> `deploy_content_finalize` (atomic promote of the finished -new tree over production)
25 *
26 * Between them, CopyEngine builds the `-new` hardlink-delta tree in bursts. Phase and progress are
27 * persisted under nfd-private so a crashed/closed request resumes instead of restarting, and every
28 * transition is mirrored into Staging's deploy-result file so the legacy `/staging/deploy` GET status
29 * poller and a recorded failure both keep working.
30 */
31class ContentCopyController {
32
33    /**
34     * Running the bash `deploy_content_setup` bookend (core download, php-ai-client, stale sweep).
35     */
36    const PHASE_SETUP = 'setup';
37
38    /**
39     * Driving CopyEngine to build the `-new` delta tree in bursts.
40     */
41    const PHASE_COPY = 'copy';
42
43    /**
44     * Running the bash `deploy_content_core` bookend (WordPress core download + php-ai-client), after
45     * the copy finishes and immediately before the swap. Its own phase so finalize stays an instant,
46     * cannot-time-out rename -- see deploy_content_core in lib/.staging.
47     */
48    const PHASE_CORE = 'core';
49
50    /**
51     * Running the bash `deploy_content_finalize` bookend (atomic promote).
52     */
53    const PHASE_FINALIZE = 'finalize';
54
55    /**
56     * Advance() calls without a successful advance for this many seconds mark a job abandoned, so a
57     * crashed/closed deploy stops blocking later staging operations forever. Generous on purpose: an
58     * active deploy rewrites the phase-state file every burst, so only a truly stalled job trips it.
59     */
60    const STALE_AFTER_SECONDS = 3600;
61
62    /**
63     * Deploy finished successfully.
64     */
65    const PHASE_DONE = 'done';
66
67    /**
68     * Deploy failed; the result file records why.
69     */
70    const PHASE_ERROR = 'error';
71
72    /**
73     * The deploy command name this copy reports as, so it shares the legacy result/log plumbing.
74     */
75    const DEPLOY_COMMAND = 'deploy_files';
76
77    /**
78     * The cross-version compat channel lib/.staging publishes the held mutex on (its LEGACY_LOCK_NAME
79     * transient). Kept in sync with that bash constant.
80     */
81    const COMPAT_CHANNEL_TRANSIENT = 'nfd_staging_lock';
82
83    /**
84     * Staging instance used to resolve paths, run bash verbs, and write the result file.
85     *
86     * @var Staging
87     */
88    private $staging;
89
90    /**
91     * Callable that runs a bash verb and returns its array|WP_Error response.
92     *
93     * @var callable
94     */
95    private $verb_runner;
96
97    /**
98     * Lazily constructed copy engine.
99     *
100     * @var CopyEngine|null
101     */
102    private $engine;
103
104    /**
105     * Lazily constructed staging-lock accessor.
106     *
107     * @var StagingCommandLock|null
108     */
109    private $lock;
110
111    /**
112     * Construct a controller bound to a Staging instance.
113     *
114     * @param Staging       $staging     Staging instance (resolves dirs, runs verbs, writes result).
115     * @param callable|null $verb_runner Optional override for running a bash verb; defaults to
116     *                                   Staging::runContentDeployVerb (used by tests to inject).
117     */
118    public function __construct( Staging $staging, $verb_runner = null ) {
119        $this->staging     = $staging;
120        $this->verb_runner = is_callable( $verb_runner )
121            ? $verb_runner
122            : array( $staging, 'runContentDeployVerb' );
123    }
124
125    /**
126     * Absolute job directory for the engine's manifest/cursor/lock and this controller's phase file.
127     *
128     * @return string
129     */
130    private function jobDir() {
131        return trailingslashit( $this->staging->getProductionDir() ) . 'nfd-private/copy-deploy-files';
132    }
133
134    /**
135     * Absolute path to the persisted phase-state file.
136     *
137     * @return string
138     */
139    private function statePath() {
140        return $this->jobDir() . '/phase-state.json';
141    }
142
143    /**
144     * Resolve the CopyEngine config for a deploy-files content copy.
145     *
146     * @return array
147     */
148    public function engineConfig() {
149        $production = untrailingslashit( $this->staging->getProductionDir() );
150        $staging    = untrailingslashit( $this->staging->getStagingDir() );
151
152        return array(
153            'source_root'    => $staging . '/wp-content',
154            'dest_root'      => $production . '/wp-content',
155            'dest_suffix'    => '-new',
156            'link_dest_root' => $production . '/wp-content',
157            'job_dir'        => $this->jobDir(),
158        );
159    }
160
161    /**
162     * Lazily build (and reuse) the copy engine.
163     *
164     * @return CopyEngine
165     */
166    private function engine() {
167        if ( null === $this->engine ) {
168            $this->engine = new CopyEngine( $this->engineConfig() );
169        }
170
171        return $this->engine;
172    }
173
174    /**
175     * Lazily build (and reuse) a handle on the staging command mutex.
176     *
177     * Not the same lock as controllerLockPath(), which only serializes two concurrent advance() calls
178     * of THIS job. This one is the site-wide mutex shared with lib/.staging, and it is what keeps a
179     * clone, create, destroy or legacy deploy from running against the same trees while this job is
180     * mid-copy.
181     *
182     * @return StagingCommandLock
183     */
184    private function lock() {
185        if ( null === $this->lock ) {
186            $this->lock = new StagingCommandLock( $this->staging->getProductionDir() );
187        }
188
189        return $this->lock;
190    }
191
192    /**
193     * This job's staging-lock token: the identity its three bash bookends present to lib/.staging's
194     * lock_check so all of them, and the PHP copy bursts between them, share one hold on the mutex.
195     *
196     * @param array $state Current state.
197     *
198     * @return string
199     */
200    private function jobToken( array $state ) {
201        // String-checked rather than cast: phase-state.json is read back with json_decode, so a
202        // truncated or hand-edited file can put an array or object here, and casting that would raise
203        // an "Array to string conversion" notice -- printed into the REST response body, where it
204        // breaks the JSON the poll loop parses. An unusable token reads as no token, which fails
205        // closed (every path that needs one refuses without it).
206        return isset( $state['lock_token'] ) && is_string( $state['lock_token'] ) ? $state['lock_token'] : '';
207    }
208
209    /**
210     * Absolute path to the controller's advisory lock file.
211     *
212     * @return string
213     */
214    private function controllerLockPath() {
215        return $this->jobDir() . '/controller.lock';
216    }
217
218    /**
219     * Do at most one bounded unit of work (one bash verb OR one engine burst) and return progress.
220     *
221     * The whole call runs under a non-blocking advisory lock, so a second caller -- a duplicate REST
222     * poll, the cron fallback, or a second browser tab -- can never run a phase transition concurrently
223     * (e.g. a setup that `rm -rf`s `-new` while another call's copy burst is filling it). A caller that
224     * cannot take the lock just reports current progress.
225     *
226     * @param bool $restart When true, discard any prior job (engine cursor/manifest + phase state) and
227     *                      start fresh. The UI passes this on the first advance of a new deploy so a
228     *                      previous run's terminal 'done'/'error' state does not short-circuit it.
229     *
230     * @return array { status: running|success|error, progress: array, phase: string, message?: string }
231     */
232    public function advance( $restart = false ) {
233        $dir = $this->jobDir();
234        if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
235            return array(
236                'status'   => 'error',
237                'phase'    => self::PHASE_ERROR,
238                'progress' => array(),
239                'message'  => __( 'Could not create the content copy job directory.', 'wp-module-staging' ),
240            );
241        }
242
243        $lock = fopen( $this->controllerLockPath(), 'cb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
244        if ( false === $lock ) {
245            return array(
246                'status'   => 'error',
247                'phase'    => self::PHASE_ERROR,
248                'progress' => array(),
249                'message'  => __( 'Could not open the content copy lock.', 'wp-module-staging' ),
250            );
251        }
252        if ( ! flock( $lock, LOCK_EX | LOCK_NB ) ) {
253            fclose( $lock );
254            return $this->status(); // Another advance() is in flight; report without mutating.
255        }
256
257        try {
258            if ( $restart ) {
259                $this->resetLocked();
260            } elseif ( $this->jobExists() && $this->isStale() ) {
261                // A non-restart advance for a job that has not progressed in the stale window: it was
262                // abandoned (a tab asleep through a laptop suspend, say). Resuming it would run the copy
263                // against a tree another operation (clone/destroy) may have since mutated. Refuse and mark
264                // it failed; the client must start a fresh deploy (restart=true), which reseeds cleanly.
265                $stale          = $this->loadState( false );
266                $stale['phase'] = self::PHASE_ERROR;
267                $stale['message'] = __( 'The deploy was abandoned and timed out. Start it again.', 'wp-module-staging' );
268                $this->saveState( $stale );
269                return $this->envelope( 'error', $stale );
270            }
271
272            $state = $this->loadState( true );
273
274            switch ( $state['phase'] ) {
275                case self::PHASE_SETUP:
276                    return $this->runSetup( $state );
277                case self::PHASE_COPY:
278                    return $this->runCopyBurst( $state );
279                case self::PHASE_CORE:
280                    return $this->runCore( $state );
281                case self::PHASE_FINALIZE:
282                    return $this->runFinalize( $state );
283                case self::PHASE_ERROR:
284                    return $this->envelope( 'error', $state );
285                case self::PHASE_DONE:
286                default:
287                    return $this->envelope( 'success', $state );
288            }
289        } finally {
290            flock( $lock, LOCK_UN );
291            fclose( $lock );
292        }
293    }
294
295    /**
296     * Discard any prior job so a new deploy starts clean. Clears the engine's cursor/manifest/lock and
297     * the phase-state file together, so the next setup sweeps stale `-new`/`-old` and the engine reseeds
298     * rather than resuming a previous run's cursor against a swept tree. Call only under the lock.
299     *
300     * @return void
301     */
302    private function resetLocked() {
303        /*
304         * Release BEFORE the state file goes, because the state file holds the only record of the
305         * token. Deleting it first stranded the lock permanently: no later call could name the token,
306         * so nothing could ever release it and the site waited out the full TTL. That was reachable
307         * from an ordinary retry -- the UI resends `restart` after a gateway timeout, and a reload or
308         * a second tab does the same -- so the common case of "the first poll took too long" cost the
309         * user half an hour of refusals.
310         *
311         * A restart is also exactly when this is safe: the caller is abandoning the old job, so there
312         * is nothing of ours left that the lock protects. releaseIfOurs still declines a lock whose
313         * recorded holder is running, or cannot be shown not to be -- the one case where something of
314         * ours may still be writing, such as an orphaned setup part-way through its sweep.
315         */
316        if ( is_file( $this->statePath() ) ) {
317            $old_token = $this->jobToken( $this->loadState( false ) );
318            $this->lock()->releaseIfOurs( $old_token );
319            $this->withdrawCompatChannel( $old_token );
320        }
321
322        $this->engine()->cleanup();
323        if ( is_file( $this->statePath() ) ) {
324            wp_delete_file( $this->statePath() );
325        }
326    }
327
328    /**
329     * Withdraw this job's publication from the cross-version compat channel (lib/.staging's
330     * LEGACY_LOCK_NAME transient) so a restart or retry is not blocked by a token a failed job left
331     * behind.
332     *
333     * A stepped deploy's setup announces `active:<ts>:<token>` on this transient, so a peer running an
334     * older copy of the script (which cannot see the lock FILE) still sees the mutex held. If the job
335     * then fails while holding the lock, nothing withdraws that announcement: the failing verb's
336     * release path leaves the held lock in place, and the transient ages out only at LOCK_TTL. A fresh
337     * deploy carries a new token, so lock_check's legacy_lock_held sees a foreign, still-fresh holder
338     * and refuses every verb, blocking the retry the user just asked for for up to half an hour.
339     * Clearing our own prior publication on restart closes that window.
340     *
341     * Cleared directly on production's options table via $wpdb (bypassing staging's prefix and any
342     * persistent object cache, matching lib/.staging's legacy_lock_announce). Guarded by token so a
343     * concurrent operation's publication is never touched.
344     *
345     * @param string $token This job's lock token (the one being discarded).
346     *
347     * @return void
348     */
349    private function withdrawCompatChannel( $token ) {
350        $this->staging->withdrawCompatLockPublication( $token );
351    }
352
353    /**
354     * Report current phase/progress without advancing.
355     *
356     * @return array
357     */
358    public function status() {
359        if ( ! $this->jobExists() ) {
360            return array(
361                'status'   => 'idle',
362                'phase'    => 'idle',
363                'progress' => array(),
364            );
365        }
366
367        $state = $this->loadState( false );
368
369        return $this->envelope( $this->phaseStatus( $state['phase'] ), $state );
370    }
371
372    /**
373     * Whether a job exists and has not yet reached a terminal phase.
374     *
375     * @return bool
376     */
377    public function isInProgress() {
378        if ( ! $this->jobExists() ) {
379            return false;
380        }
381
382        $state = $this->loadState( false );
383
384        $active = in_array(
385            $state['phase'],
386            array( self::PHASE_SETUP, self::PHASE_COPY, self::PHASE_CORE, self::PHASE_FINALIZE ),
387            true
388        );
389        if ( ! $active ) {
390            return false;
391        }
392
393        // An abandoned job (crashed/closed before reaching a terminal phase) would otherwise block every
394        // later staging operation forever, since nothing ever flips its phase to done/error. Treat a job
395        // that has not advanced within the stale window as dead, not in flight.
396        return ! $this->isStale();
397    }
398
399    /**
400     * Whether a job's phase-state file has not been touched within the stale window. advance() rewrites
401     * it on every burst (writeRunning/saveState), so an older mtime means no progress for that long.
402     * Only meaningful when a job exists.
403     *
404     * @return bool
405     */
406    private function isStale() {
407        $mtime = @filemtime( $this->statePath() );
408        return false !== $mtime && $mtime < ( time() - self::STALE_AFTER_SECONDS );
409    }
410
411    /**
412     * Phase 1: run the bash setup bookend, then move to the copy phase.
413     *
414     * @param array $state Current state.
415     *
416     * @return array
417     */
418    private function runSetup( $state ) {
419        // Start the engine from a clean slate every time setup runs. A support engineer who deletes the
420        // phase-state file (but not the engine's cursor) would otherwise fall back to the setup phase
421        // while a STALE cursor still points mid-tree; the copy phase would then resume from it and
422        // promote only the tail of the tree over production. cleanup() removes the cursor/manifest so the
423        // engine reseeds, matching the stale -new/-old sweep the bash setup bookend does on the files.
424        $this->engine()->cleanup();
425
426        $this->writeRunning( $state );
427
428        $response = $this->callVerb( 'deploy_content_setup', $state );
429        if ( $this->verbFailed( $response ) ) {
430            // Setup is the verb most likely to outlive its request: its stale-`-new` sweep is one
431            // unlink per file. So it is also the one whose orphan the next poll runs into, and that
432            // refusal must not be read as a broken deploy.
433            $running = $this->ownStepStillRunning( $state );
434            if ( null !== $running ) {
435                return $running;
436            }
437
438            return $this->fail( $state, $this->verbMessage( $response, __( 'Content deploy setup failed.', 'wp-module-staging' ) ) );
439        }
440
441        $state['phase'] = self::PHASE_COPY;
442        $this->saveState( $state );
443        $this->writeRunning( $state );
444
445        return $this->envelope( 'running', $state );
446    }
447
448    /**
449     * Confirm this job still holds the staging mutex, and build the failure envelope if it does not.
450     *
451     * Every phase that is about to act on the trees has to ask, not just the copy. lib/.staging's
452     * lock_check cannot answer it for us: presented with no lock file at all it simply acquires one,
453     * so a verb could not tell "we have held this all along" from "we lost it, and whatever took it
454     * has since finished and released". The difference matters. The failure that motivates this: the
455     * last copy burst moves the job to the core phase, the poll driver then stalls for longer than
456     * the lock TTL but less than the job's stale window (a suspended laptop, a starved cron), a clone
457     * takes the lock, rewrites staging from production, finishes and releases -- and the core and
458     * finalize phases would then promote a `-new` tree built from the pre-clone staging content over
459     * production, because the clone only ever touched staging and left the `-new` tree standing.
460     *
461     * Either operation answers it, because both only succeed on a lock that exists and still carries
462     * this job's token -- false while a thief holds it and false after a thief has released it.
463     *
464     * WHICH ONE MATTERS. $refresh also WRITES, and the value it writes records pid 0, meaning "no
465     * single process owns this right now". Only the copy phase may say that: it is the one phase with
466     * nothing of the job's running between calls, and the refresh is also what keeps the lock from
467     * ageing out across a long copy. The phases that invoke a bash verb must NOT write, because the
468     * pid already in the lock may belong to a verb of this same job that is still running -- a
469     * finalize whose PHP request was killed by the FPM timeout while the bash process carried on
470     * swapping directories. That pid is the only thing standing between the next poll and a SECOND
471     * concurrent finalize: lock_check refuses to reclaim a lock whose recorded process is alive, and
472     * zeroing it here would tell it there is nobody to wait for, mid-swap. So core and finalize check
473     * read-only and leave the pid alone; the verb's own lock_check refreshes the timestamp a moment
474     * later anyway.
475     *
476     * @param array $state   Current state.
477     * @param array $failure Set to the failure envelope when this returns false.
478     * @param bool  $refresh Whether to also push the lock's timestamp forward (copy phase only).
479     *
480     * @return bool
481     */
482    private function holdsLockOrFail( $state, &$failure, $refresh ) {
483        $token = $this->jobToken( $state );
484        $held  = $refresh ? $this->lock()->refresh( $token ) : $this->lock()->holds( $token );
485
486        if ( $held ) {
487            return true;
488        }
489
490        $failure = $this->fail(
491            $state,
492            __( 'The deploy lost its lock to another staging operation and was stopped before changing production. Start the deploy again.', 'wp-module-staging' )
493        );
494
495        return false;
496    }
497
498    /**
499     * Phase 2: run one CopyEngine burst. Still reports running when the copy completes, because the
500     * core-download and finalize bookends have not run yet, so the poll driver keeps calling advance().
501     *
502     * @param array $state Current state.
503     *
504     * @return array
505     */
506    private function runCopyBurst( $state ) {
507        // Every burst re-asserts the mutex, which is also what keeps it alive: nothing of ours runs
508        // between bursts, so without a refresh here the lock setup took would age past its TTL
509        // mid-copy and lib/.staging would hand the mutex to the next operation that asked -- which
510        // would then mutate the very trees this job is enumerating and writing into.
511        if ( ! $this->holdsLockOrFail( $state, $failure, true ) ) {
512            return $failure;
513        }
514
515        $result            = $this->engine()->advance();
516        $state['progress'] = isset( $result['progress'] ) ? $result['progress'] : array();
517
518        if ( 'error' === $result['status'] ) {
519            return $this->fail( $state, isset( $result['message'] ) ? $result['message'] : __( 'Copying content files failed.', 'wp-module-staging' ) );
520        }
521
522        if ( 'success' === $result['status'] ) {
523            $state['phase'] = self::PHASE_CORE;
524        }
525
526        $this->saveState( $state );
527        $this->writeRunning( $state );
528
529        return $this->envelope( 'running', $state );
530    }
531
532    /**
533     * Phase 2.5: run the bash core-download bookend (WordPress core + php-ai-client), then move to
534     * finalize. Kept distinct from finalize so finalize is only the instant swap -- see
535     * deploy_content_core in lib/.staging. Still reports running so the poll driver continues.
536     *
537     * @param array $state Current state.
538     *
539     * @return array
540     */
541    private function runCore( $state ) {
542        // Read-only: this phase invokes a bash verb, so it must not overwrite the pid in the
543        // lock. See holdsLockOrFail.
544        if ( ! $this->holdsLockOrFail( $state, $failure, false ) ) {
545            return $failure;
546        }
547
548        $response = $this->callVerb( 'deploy_content_core', $state );
549        if ( $this->verbFailed( $response ) ) {
550            $running = $this->ownStepStillRunning( $state );
551            if ( null !== $running ) {
552                return $running;
553            }
554
555            return $this->fail( $state, $this->verbMessage( $response, __( 'Preparing the WordPress core for deploy failed.', 'wp-module-staging' ) ) );
556        }
557
558        $state['phase'] = self::PHASE_FINALIZE;
559        $this->saveState( $state );
560        $this->writeRunning( $state );
561
562        return $this->envelope( 'running', $state );
563    }
564
565    /**
566     * Phase 3: run the bash finalize bookend, clean up the engine, mark done.
567     *
568     * @param array $state Current state.
569     *
570     * @return array
571     */
572    private function runFinalize( $state ) {
573        /*
574         * A finalize this job already ran may have SUCCEEDED without this phase ever being told.
575         *
576         * deploy_content_finalize swaps, releases the mutex, and only THEN deletes the `-old` asides,
577         * which on a normal deploy is the only step whose cost scales with the site's file count (the
578         * sweep in deploy_content_setup is the other, and only after an abandoned attempt). The point of that
579         * ordering is that the request blocking on it is free to die: by then production is live and
580         * correct. But the request dying is also what stops saveState(PHASE_DONE) below from running,
581         * so the job is still recorded as `finalize`, and the next poll re-enters here.
582         *
583         * Without this check, that poll found the lock gone (the verb released it) and reported "the
584         * deploy lost its lock and was stopped BEFORE changing production" -- on a deploy that had
585         * completed. That is the exact symptom the release-then-delete ordering was built to remove,
586         * reintroduced on the PHP side.
587         */
588        if ( $this->swapAlreadyCompleted( $state ) ) {
589            return $this->finishLocked( $state );
590        }
591
592        // Read-only: this phase invokes a bash verb, so it must not overwrite the pid in the
593        // lock. See holdsLockOrFail.
594        if ( ! $this->holdsLockOrFail( $state, $failure, false ) ) {
595            return $failure;
596        }
597
598        /*
599         * Recorded BEFORE the verb runs, and durably, because the process that learns the answer may
600         * not survive to report it. It is what lets the check above tell "a finalize of ours ran and
601         * the trees are gone because it promoted them" from "the copy phase produced nothing to
602         * promote in the first place". Both look identical on disk.
603         */
604        if ( empty( $state['finalize_attempted'] ) ) {
605            $state['finalize_attempted'] = true;
606            $this->saveState( $state );
607        }
608
609        $response = $this->callVerb( 'deploy_content_finalize', $state );
610        if ( $this->verbFailed( $response ) ) {
611            // An earlier finalize of this same job may have got further than the one we just tried.
612            if ( $this->swapAlreadyCompleted( $state ) ) {
613                return $this->finishLocked( $state );
614            }
615
616            $running = $this->ownStepStillRunning( $state );
617            if ( null !== $running ) {
618                return $running;
619            }
620
621            return $this->fail( $state, $this->verbMessage( $response, __( 'Finalizing the content deploy failed.', 'wp-module-staging' ) ) );
622        }
623
624        return $this->finishLocked( $state );
625    }
626
627    /**
628     * The `running` envelope to return when a verb was refused because an earlier verb of THIS job is
629     * still working, or null when that is not what happened.
630     *
631     * lock_check will not reclaim a lock whose recorded process is alive, and it reports that with the
632     * same customer-facing message as every other refusal, so the message cannot be used to tell them
633     * apart. When the live holder is one of our own verbs -- a bookend that outlived the request that
634     * started it and is still running -- the job is progressing, not broken, and the poll driver has
635     * to be told to come back rather than shown a failure.
636     *
637     * Deliberately does NOT save the phase state. advance()'s stale window is measured from the last
638     * state write, so leaving it alone is what bounds this: a verb that hangs for ever ends as a
639     * failed job instead of polling for ever.
640     *
641     * @param array $state Current state.
642     *
643     * @return array|null
644     */
645    private function ownStepStillRunning( array $state ) {
646        if ( ! $this->lock()->heldByOwnLiveProcess( $this->jobToken( $state ) ) ) {
647            return null;
648        }
649
650        $this->writeRunning( $state );
651
652        return $this->envelope( 'running', $state );
653    }
654
655    /**
656     * Whether the swap this job was running has already finished, leaving nothing to promote.
657     *
658     * Only ever true once a finalize has actually been invoked (see `finalize_attempted`): a job whose
659     * staging source had no content directories at all would otherwise have no `-new` trees from the
660     * start and would be reported as a completed swap without the verb ever running, which would also
661     * skip releasing the mutex.
662     *
663     * "Nothing to promote" is the same test bash itself uses to decide a directory is already done
664     * (see finalize_new_content_dirs), and the states it could be confused with do not arise: a verb
665     * killed mid-swap leaves the later directories' `-new` in place AND leaves its lock behind, and a
666     * verb that failed and rolled back restores `-new` from each directory it had promoted.
667     *
668     * @param array $state Current state.
669     *
670     * @return bool
671     */
672    private function swapAlreadyCompleted( array $state ) {
673        if ( empty( $state['finalize_attempted'] ) ) {
674            return false;
675        }
676
677        $config = $this->engineConfig();
678        foreach ( CopyEngine::DEFAULT_ROOTS as $dir ) {
679            if ( is_dir( $config['dest_root'] . '/' . $dir . $config['dest_suffix'] ) ) {
680                return false;
681            }
682        }
683
684        return true;
685    }
686
687    /**
688     * Record a completed deploy. Shared by the normal path and by the resumption check above, so a
689     * deploy finished by an orphaned verb is reported exactly like one finished in place.
690     *
691     * @param array $state Current state.
692     *
693     * @return array
694     */
695    private function finishLocked( array $state ) {
696        $this->engine()->cleanup();
697
698        /*
699         * Normally the verb has already released the mutex by this point. It has not if the swap
700         * completed but the verb was killed before its release, so this is a best-effort tidy-up:
701         * releaseIfOurs only acts on a lock that is ours, records no bash pid, and is far enough
702         * inside the TTL that nobody may concurrently steal it.
703         */
704        $this->lock()->releaseIfOurs( $this->jobToken( $state ) );
705
706        $state['phase'] = self::PHASE_DONE;
707        $this->saveState( $state );
708        $this->writeSuccess( $state );
709
710        return $this->envelope( 'success', $state );
711    }
712
713    /**
714     * Invoke a bash verb through the configured runner, handing it this job's lock token.
715     *
716     * @param string $verb  Verb name.
717     * @param array  $state Current state, which carries this job's lock token.
718     *
719     * @return array|\WP_Error
720     */
721    private function callVerb( $verb, array $state ) {
722        return call_user_func( $this->verb_runner, $verb, $this->jobToken( $state ) );
723    }
724
725    /**
726     * Whether a verb response indicates failure.
727     *
728     * @param mixed $response Verb response.
729     *
730     * @return bool
731     */
732    private function verbFailed( $response ) {
733        if ( is_wp_error( $response ) ) {
734            return true;
735        }
736
737        return is_array( $response ) && isset( $response['status'] ) && 'error' === $response['status'];
738    }
739
740    /**
741     * Extract a human message from a failed verb response.
742     *
743     * @param mixed  $response Verb response.
744     * @param string $default  Fallback message.
745     *
746     * @return string
747     */
748    private function verbMessage( $response, $default ) {
749        if ( is_wp_error( $response ) ) {
750            return $response->get_error_message();
751        }
752
753        if ( is_array( $response ) && ! empty( $response['message'] ) ) {
754            return (string) $response['message'];
755        }
756
757        return $default;
758    }
759
760    /**
761     * Record a failure: mark the error phase and persist it to the result file.
762     *
763     * @param array  $state   Current state.
764     * @param string $message Failure message.
765     *
766     * @return array
767     */
768    private function fail( $state, $message ) {
769        $state['phase']   = self::PHASE_ERROR;
770        $state['message'] = $message;
771        $this->saveState( $state );
772
773        /*
774         * Give the mutex back. Every bash failure path releases through error(), but a failure on the
775         * PHP side -- an unreadable manifest, a full disk, a directory the account cannot enter --
776         * had no release at all, so the lock the setup verb took stayed until its TTL ran out and
777         * every staging operation on the site was refused for up to half an hour over a deploy that
778         * was already over.
779         *
780         * releaseIfOurs is the right tool rather than an unlink because it refuses anything it cannot
781         * prove is free to delete: a lock carrying another token, one recording a bash pid (a verb may
782         * still be in flight under it, including an orphaned setup whose `rm -rf` is still running),
783         * or one close enough to the TTL that lib/.staging may concurrently decide it is abandoned.
784         */
785        $this->lock()->releaseIfOurs( $this->jobToken( $state ) );
786        $this->withdrawCompatChannel( $this->jobToken( $state ) );
787
788        $this->staging->recordDeployResult(
789            array(
790                'status'     => 'error',
791                'command'    => self::DEPLOY_COMMAND,
792                'started_at' => $state['started_at'],
793                'message'    => $message,
794            )
795        );
796
797        return $this->envelope( 'error', $state, $message );
798    }
799
800    /**
801     * Write a running snapshot to the shared deploy-result file.
802     *
803     * @param array $state Current state.
804     *
805     * @return void
806     */
807    private function writeRunning( $state ) {
808        $this->staging->recordDeployResult(
809            array(
810                'status'     => 'running',
811                'command'    => self::DEPLOY_COMMAND,
812                'started_at' => $state['started_at'],
813                'message'    => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
814                'progress'   => isset( $state['progress'] ) ? $state['progress'] : array(),
815            )
816        );
817    }
818
819    /**
820     * Write the final success result to the shared deploy-result file.
821     *
822     * @param array $state Current state.
823     *
824     * @return void
825     */
826    private function writeSuccess( $state ) {
827        $this->staging->recordDeployResult(
828            array(
829                'status'     => 'success',
830                'command'    => self::DEPLOY_COMMAND,
831                'started_at' => $state['started_at'],
832                'message'    => __( 'Files deployed successfully.', 'wp-module-staging' ),
833                'progress'   => isset( $state['progress'] ) ? $state['progress'] : array(),
834            )
835        );
836    }
837
838    /**
839     * Build the standard return envelope from the current state.
840     *
841     * @param string $status  running|success|error|idle.
842     * @param array  $state   Current state.
843     * @param string $message Optional message override.
844     *
845     * @return array
846     */
847    private function envelope( $status, $state, $message = '' ) {
848        $out = array(
849            'status'   => $status,
850            'phase'    => $state['phase'],
851            'progress' => isset( $state['progress'] ) ? $state['progress'] : array(),
852        );
853
854        if ( '' !== $message ) {
855            $out['message'] = $message;
856        } elseif ( ! empty( $state['message'] ) ) {
857            $out['message'] = $state['message'];
858        }
859
860        return $out;
861    }
862
863    /**
864     * Map a phase to its outward status.
865     *
866     * @param string $phase Phase name.
867     *
868     * @return string
869     */
870    private function phaseStatus( $phase ) {
871        switch ( $phase ) {
872            case self::PHASE_DONE:
873                return 'success';
874            case self::PHASE_ERROR:
875                return 'error';
876            default:
877                return 'running';
878        }
879    }
880
881    /**
882     * Whether a persisted job-state file exists.
883     *
884     * @return bool
885     */
886    private function jobExists() {
887        return is_file( $this->statePath() );
888    }
889
890    /**
891     * Load the phase state, optionally creating a fresh job file if none exists.
892     *
893     * @param bool $create_if_missing When true, seed and persist a fresh setup-phase state.
894     *
895     * @return array
896     */
897    private function loadState( $create_if_missing ) {
898        $path = $this->statePath();
899        if ( is_file( $path ) ) {
900            $raw     = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
901            $decoded = $raw ? json_decode( $raw, true ) : null;
902            if ( is_array( $decoded ) ) {
903                return array_merge( $this->defaultState(), $decoded );
904            }
905        }
906
907        $state = $this->defaultState();
908        if ( $create_if_missing ) {
909            $this->saveState( $state );
910        }
911
912        return $state;
913    }
914
915    /**
916     * The initial phase state for a fresh job.
917     *
918     * @return array
919     */
920    private function defaultState() {
921        return array(
922            'phase'      => self::PHASE_SETUP,
923            'started_at' => time(),
924            'progress'   => array(),
925            'message'    => '',
926            'lock_token' => self::newLockToken(),
927        );
928    }
929
930    /**
931     * Mint a staging-lock token: 32 hex characters, from a generator that never calls into
932     * WordPress. The token has to be alphanumeric because it travels as a shell argument and as a
933     * field in a colon-delimited lock value; see Staging::randomToken for why wp_generate_password
934     * is the wrong tool for it.
935     *
936     * @return string
937     */
938    private static function newLockToken() {
939        return Staging::randomToken();
940    }
941
942    /**
943     * Persist the phase state atomically (temp file + rename).
944     *
945     * @param array $state State to persist.
946     *
947     * @return void
948     */
949    private function saveState( array $state ) {
950        $dir = $this->jobDir();
951        if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
952            return;
953        }
954
955        $json = wp_json_encode( $state );
956        if ( false === $json ) {
957            return;
958        }
959
960        $tmp = $this->statePath() . '.tmp-' . getmypid();
961        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
962        if ( false === file_put_contents( $tmp, $json, LOCK_EX ) ) {
963            return;
964        }
965        if ( ! rename( $tmp, $this->statePath() ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rename
966            wp_delete_file( $tmp );
967        }
968    }
969}