feat: M3 edit + single-step re-run — input versioning, human-modified audit, staged re-run (v0.20.0) (#161)

QA: REJECT@6ed639d (CTO rebase header P1) -> APPROVE@6727e07 (qa-reviews/prautoblogger/2026-06-12-6727e07.md)
CPO guardrails 1-5 verified hard-AC. CEO conditional greenlight 2026-06-12 in chat (QA-APPROVE condition met).

Agent-Session: cto-m3-merge-20260612
This commit is contained in:
peptiderepo
2026-06-12 21:46:49 +07:00
committed by GitHub
parent 3b6a302f95
commit ac2f20de7e
55 changed files with 5826 additions and 137 deletions

View File

@@ -127,7 +127,7 @@ class PRAutoBlogger_Board_Data_Provider {
);
}
return $cards;
return $this->flag_human_modified( $cards );
}
/**
@@ -183,6 +183,48 @@ class PRAutoBlogger_Board_Data_Provider {
);
}
return $this->flag_human_modified( $cards );
}
/**
* Set 'human_modified' on each card in ONE batched query (v0.20.0 —
* CPO product AC: human-modified runs are visually distinct at the
* run-list level). No per-card queries; self-healing on a missing
* table/column (flag simply stays false).
*
* @param array<int, array<string, mixed>> $cards Column cards.
* @return array<int, array<string, mixed>> Cards with the flag set.
*/
private function flag_human_modified( array $cards ): array {
$run_ids = array();
foreach ( $cards as $i => $card ) {
$cards[ $i ]['human_modified'] = false;
if ( ! empty( $card['run_id'] ) ) {
$run_ids[] = (string) $card['run_id'];
}
}
if ( empty( $run_ids ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return $cards;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$flagged = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT run_id FROM {$table} WHERE human_modified = 1 AND run_id IN ({$placeholders})",
$run_ids
)
);
if ( ! is_array( $flagged ) || empty( $flagged ) ) {
return $cards;
}
$flagged = array_flip( array_map( 'strval', $flagged ) );
foreach ( $cards as $i => $card ) {
if ( isset( $flagged[ (string) ( $card['run_id'] ?? '' ) ] ) ) {
$cards[ $i ]['human_modified'] = true;
}
}
return $cards;
}
}

View File

@@ -152,6 +152,7 @@ class PRAutoBlogger_Board_Page {
'edit' => __( 'Edit', 'prautoblogger' ),
'viewLog' => __( 'View Log', 'prautoblogger' ),
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
),
)
);

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX endpoints for the dossier's edit + re-run actions (v0.20.0, M3).
*
* What: Four manage_options + nonce gated endpoints. The mutation
* endpoints follow the chained-cron contract strictly: save_input
* only INSERTs an immutable fork version; the two rerun endpoints
* only validate + queue (Rerun_Job_Support::queue) — execution and
* ALL run/stage state mutations happen in the cron handler after
* re-validation under the generation lock. Run identity (run_id /
* item_key) is always derived server-side from the post's meta —
* never trusted from the client; the client only names the stage
* row it acted on, which is validated against an existing row.
* stage_status is the read-only poll the dossier JS uses to show
* queued -> pickup -> result.
* Who triggers it: assets/js/dossier-edit.js via admin-ajax.php;
* registered in class-prautoblogger.php.
* Dependencies: Rerun_Eligibility, Stage_Input_Store, Stage_Replay
* (decode), Rerun_Job_Support/Executor, Run_Stage_State.
*
* @see assets/js/dossier-edit.js — The frontend caller.
* @see core/class-rerun-executor.php — The cron handlers jobs land in.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Dossier_Actions {
/** Nonce action shared by all four endpoints. */
public const NONCE_ACTION = 'prautoblogger_dossier_actions';
/**
* AJAX: save an edited stage input as a new immutable fork version.
*
* The fork body is the stage's base request (latest fork, else the
* recorded request_json) with ONLY the message contents substituted —
* roles, message count, model and call options are not editable, so
* a fork is always a well-formed replayable chat body. Prompt text
* is stored raw (it is LLM input, never rendered unescaped).
*
* Side effects: one stage_inputs INSERT.
*
* @return void Sends JSON.
*/
public function on_save_input(): void {
$request = $this->authorize_and_resolve();
$check = PRAutoBlogger_Rerun_Eligibility::check( $request['run_id'], $request['post_id'] );
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $request['stage'] ) || ! $check['ok'] ) {
$reason = $check['ok'] ? __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) : $check['reason'];
wp_send_json_error( array( 'message' => $reason ), 400 );
return;
}
$base_body = $this->resolve_base_body( $request );
if ( null === $base_body ) {
wp_send_json_error( array( 'message' => __( 'No persisted input exists for this stage.', 'prautoblogger' ) ), 400 );
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw prompt text for the LLM; structure-validated below, stored raw, escaped at render.
$raw = isset( $_POST['messages'] ) ? wp_unslash( (string) $_POST['messages'] ) : '';
$messages = json_decode( $raw, true );
$merged = $this->merge_messages( $base_body, is_array( $messages ) ? $messages : null );
if ( null === $merged ) {
wp_send_json_error( array( 'message' => __( 'Edited messages must match the original structure (same count and roles, non-empty contents).', 'prautoblogger' ) ), 400 );
return;
}
$user = wp_get_current_user();
$version = PRAutoBlogger_Stage_Input_Store::save_fork(
$request['run_id'],
$request['stage'],
$request['agent_role'],
$request['item_key'],
(string) wp_json_encode( $merged ),
(string) $user->user_login
);
if ( null === $version ) {
wp_send_json_error( array( 'message' => __( 'The edited input could not be saved.', 'prautoblogger' ) ), 500 );
return;
}
wp_send_json_success(
array(
'version' => $version,
'message' => sprintf(
/* translators: %d: fork version number. */
__( 'Saved as version %d. The original input is preserved unchanged; re-running this stage will use your edited version and mark all downstream stages stale.', 'prautoblogger' ),
$version
),
)
);
}
/**
* AJAX: queue a single-stage replay of the latest edited fork.
*
* Validates, then ONLY schedules — chained-cron semantics. The UI
* copy never implies synchronous execution.
*
* @return void Sends JSON.
*/
public function on_rerun_stage(): void {
$request = $this->authorize_and_resolve();
$check = PRAutoBlogger_Rerun_Eligibility::check_replay(
$request['run_id'],
$request['post_id'],
$request['stage'],
$request['agent_role'],
$request['item_key']
);
if ( ! $check['ok'] ) {
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
return;
}
PRAutoBlogger_Rerun_Job_Support::queue(
PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
array( $request['run_id'], $request['post_id'], $request['stage'], $request['agent_role'], $request['item_key'] ),
sprintf(
/* translators: %s: stage display label. */
__( 'Queued: re-running %s with the edited input…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
)
);
wp_send_json_success(
array(
'queued' => true,
'message' => __( 'Re-run queued. The pipeline will pick it up shortly — progress appears here and on the board.', 'prautoblogger' ),
)
);
}
/**
* AJAX: queue re-run-from-here (rebuild this stage + downstream).
*
* @return void Sends JSON.
*/
public function on_rerun_from(): void {
$request = $this->authorize_and_resolve();
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild(
$request['run_id'],
$request['post_id'],
$request['stage'],
$request['item_key']
);
if ( ! $check['ok'] ) {
wp_send_json_error( array( 'message' => $check['reason'] ), 400 );
return;
}
PRAutoBlogger_Rerun_Job_Support::queue(
PRAutoBlogger_Rerun_Executor::REBUILD_ACTION,
array( $request['run_id'], $request['post_id'], $request['stage'], $request['item_key'] ),
sprintf(
/* translators: %s: stage display label. */
__( 'Queued: re-running the pipeline from %s…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $request['stage'] )
)
);
wp_send_json_success(
array(
'queued' => true,
'message' => __( 'Re-run from here queued. Stages will rebuild in sequence — progress appears here and on the board.', 'prautoblogger' ),
)
);
}
/**
* AJAX: read-only stage-status poll for the dossier.
*
* @return void Sends JSON: run status, per-stage state, live message.
*/
public function on_stage_status(): void {
$request = $this->authorize_and_resolve( false );
$stages = array();
foreach ( PRAutoBlogger_Run_Stage_State::stages_for_item( $request['run_id'], $request['item_key'] ) as $row ) {
$stages[] = array(
'stage' => (string) ( $row['stage'] ?? '' ),
'agent_role' => (string) ( $row['agent_role'] ?? '' ),
'status' => (string) ( $row['status'] ?? '' ),
'stale' => ! empty( $row['stale'] ),
'human_modified' => ! empty( $row['human_modified'] ),
'attempt' => (int) ( $row['attempt'] ?? 1 ),
'finished_at' => (string) ( $row['finished_at'] ?? '' ),
);
}
$live = get_transient( PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT );
wp_send_json_success(
array(
'run_status' => (string) ( PRAutoBlogger_Run_State::get_status( $request['run_id'] ) ?? '' ),
'stages' => $stages,
'live' => is_array( $live ) ? $live : null,
)
);
}
/**
* Shared gate: nonce + capability, then derive run identity from the
* POSTed post_id server-side. Sends a JSON error (and exits) on any
* failure.
*
* @param bool $require_stage Whether the request must name a stage row.
* @return array{post_id: int, run_id: string, item_key: string, stage: string, agent_role: string}
*/
private function authorize_and_resolve( bool $require_stage = true ): array {
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
$run_id = $post_id > 0 ? (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ) : '';
if ( 0 === $post_id || '' === $run_id ) {
wp_send_json_error( array( 'message' => __( 'No generation run is recorded for this article.', 'prautoblogger' ) ), 400 );
}
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
$stage = isset( $_POST['stage'] ) ? sanitize_key( (string) wp_unslash( $_POST['stage'] ) ) : '';
$agent_role = isset( $_POST['agent_role'] ) ? sanitize_key( (string) wp_unslash( $_POST['agent_role'] ) ) : '';
if ( $require_stage && '' === $stage ) {
wp_send_json_error( array( 'message' => __( 'No stage specified.', 'prautoblogger' ) ), 400 );
}
return array(
'post_id' => $post_id,
'run_id' => $run_id,
'item_key' => $item_key,
'stage' => $stage,
'agent_role' => $agent_role,
);
}
/**
* The base body an edit forks from: latest saved fork, else the
* stage's recorded request_json.
*
* @param array<string, mixed> $request Resolved request context.
* @return array<string, mixed>|null Decoded body, or null when absent.
*/
private function resolve_base_body( array $request ): ?array {
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $request['run_id'], $request['stage'], $request['agent_role'], $request['item_key'] );
if ( null !== $fork && ! empty( $fork['request_json'] ) ) {
return PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] );
}
$log_rows = ( new PRAutoBlogger_Dossier_Gen_Log_Query() )->get_by_run( $request['run_id'] );
$rows = $log_rows[ $request['stage'] ] ?? array();
foreach ( array_reverse( $rows ) as $row ) {
if ( ! empty( $row['request_json'] ) ) {
$body = PRAutoBlogger_Stage_Replay::decode_body( (string) $row['request_json'] );
if ( null !== $body ) {
return $body;
}
}
}
return null;
}
/**
* Substitute edited message contents into the base body. Structure
* is locked: same message count, same roles, all contents non-empty
* strings — so every fork stays a replayable chat body.
*
* @param array<string, mixed> $base Decoded base body.
* @param array<int, mixed>|null $messages Edited messages payload.
* @return array<string, mixed>|null Merged body, or null when invalid.
*/
private function merge_messages( array $base, ?array $messages ): ?array {
if ( null === $messages || count( $messages ) !== count( $base['messages'] ) ) {
return null;
}
$merged = $base;
foreach ( array_values( $messages ) as $i => $message ) {
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
return null;
}
if ( ( $base['messages'][ $i ]['role'] ?? null ) !== $message['role'] ) {
return null;
}
if ( '' === trim( $message['content'] ) ) {
return null;
}
$merged['messages'][ $i ]['content'] = $message['content'];
}
return $merged;
}
}

View File

@@ -16,6 +16,15 @@ declare(strict_types=1);
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
* Amortized research rows (pv=null, role='') render gracefully.
*
* v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key
* from the idea-hash meta; multi-article runs no longer collide) and
* gen_log rows exclude entries linked to OTHER posts; per-stage edit/
* re-run affordance data + run spend ride the view model
* (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/
* llm_research/... -- generation_log rows with no run_stages row) and
* the post's pipeline attachments are exposed so the image sections
* render from their REAL data sources (QA M2 F3).
*
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
* PRAutoBlogger_Run_Stage_State.
@@ -55,6 +64,16 @@ class PRAutoBlogger_Dossier_Data_Assembler {
'stages' => array(),
'decisions' => array(),
'gen_log_index' => array(),
'item_key' => '',
'eligibility' => array(
'ok' => false,
'reason' => '',
),
'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ),
'human_modified_any' => false,
'stale_any' => false,
'log_only_stages' => array(),
'images' => array(),
);
if ( $post_id <= 0 ) {
@@ -82,11 +101,40 @@ class PRAutoBlogger_Dossier_Data_Assembler {
return $base;
}
$idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true );
$item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : '';
$base['item_key'] = $item_key;
$base['has_run'] = true;
$base['run'] = $run;
$base['stages'] = $this->get_stage_rows( $run_id );
$base['stages'] = $this->get_stage_rows( $run_id, $item_key );
$base['decisions'] = $this->get_decision_rows( $run_id );
$base['gen_log_index'] = $this->log_query->get_by_run( $run_id );
$base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id );
$base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id );
$base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run );
foreach ( $base['stages'] as $role_key => $stage_row ) {
$stage_name = (string) ( $stage_row['stage'] ?? '' );
$rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage(
$stage_row,
$base['gen_log_index'][ $stage_name ] ?? array(),
$run_id,
$base['eligibility']
);
$base['stages'][ $role_key ]['rerun'] = $rerun;
$base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified'];
$base['stale_any'] = $base['stale_any'] || $rerun['stale'];
}
// QA M2 F3: stages that exist only in generation_log (image_a/
// image_b, llm_research, image_prompt_rewrite, ...) render from
// their real source instead of silently disappearing.
$run_stage_names = array();
foreach ( $base['stages'] as $stage_row ) {
$run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true;
}
$base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) );
$base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id );
return $base;
}
@@ -112,12 +160,14 @@ class PRAutoBlogger_Dossier_Data_Assembler {
}
/**
* Fetch all run_stages rows for a run, indexed by stage name.
* Fetch run_stages rows for a run, indexed by stage name — scoped to
* one item (+ run-level rows) when an item key is known.
*
* @param string $run_id Pipeline run UUID.
* @param string $run_id Pipeline run UUID.
* @param string $item_key Item key ('' = no scoping, M2 behavior).
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
*/
private function get_stage_rows( string $run_id ): array {
private function get_stage_rows( string $run_id, string $item_key = '' ): array {
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return array();
}
@@ -126,11 +176,22 @@ class PRAutoBlogger_Dossier_Data_Assembler {
return array();
}
$table = PRAutoBlogger_Run_Stage_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
ARRAY_A
);
// v0.20.0: scope to THIS post's item + run-level rows. When the
// idea-hash meta is absent (pre-v0.18 posts) fall back to the
// unfiltered M2 behavior (single-item runs are unaffected).
if ( '' !== $item_key ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ),
ARRAY_A
);
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
ARRAY_A
);
}
$index = array();
foreach ( ( $rows ?? array() ) as $row ) {
@@ -145,6 +206,29 @@ class PRAutoBlogger_Dossier_Data_Assembler {
return $index;
}
/**
* Drop gen_log rows linked to OTHER posts (multi-article runs share
* one run_id). Rows with no post linkage (NULL/0 — run-level work
* and pre-link entries) are kept.
*
* @param array<string, array<int, array<string, mixed>>> $log_index Rows keyed by stage.
* @param int $post_id This dossier's post.
* @return array<string, array<int, array<string, mixed>>>
*/
private function filter_log_for_post( array $log_index, int $post_id ): array {
$filtered = array();
foreach ( $log_index as $stage => $rows ) {
foreach ( $rows as $row ) {
$row_post = (int) ( $row['post_id'] ?? 0 );
if ( 0 !== $row_post && $row_post !== $post_id ) {
continue;
}
$filtered[ $stage ][] = $row;
}
}
return $filtered;
}
/**
* Extract the text output from a run_stages meta_json blob.
* Returns null when meta_json is NULL (pruned) or absent.

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Resolves a post's pipeline image attachments for the dossier (M3/F3).
*
* What: The image sections' REAL data source — the attachments the
* image pipeline produced (featured + composed og/square variants,
* discovered via the `_prautoblogger_image_role` attachment meta
* and the post thumbnail), paired in the template with the
* image_a/image_b generation_log rows. Replaces the M2 sections
* that keyed off run_stages rows the image pipeline never writes
* (QA M2 F3 — wiring the dossier to real data was the smaller,
* safer change vs. teaching the live image pipeline to write
* run_stages; the pipeline restructure is Phase 2b).
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
* Dependencies: get_children(), attachment meta, wp_get_attachment_image_url().
*
* @see admin/class-dossier-data-assembler.php — Caller.
* @see templates/admin/dossier-log-stage-section.php — Renders the pairs.
* @see ARCHITECTURE.md #24 — M3 design (F3 note).
*/
class PRAutoBlogger_Dossier_Image_Data {
/**
* The post's pipeline attachments (featured/og/square) via the
* `_prautoblogger_image_role` attachment meta — the image sections'
* real data source (QA M2 F3).
*
* @param int $post_id Post ID.
* @return array<int, array{id: int, role: string, url: string, is_featured: bool}>
*/
public static function for_post( int $post_id ): array {
$featured_id = (int) get_post_thumbnail_id( $post_id );
$children = get_children(
array(
'post_parent' => $post_id,
'post_type' => 'attachment',
'numberposts' => 12,
)
);
$images = array();
$seen = array();
foreach ( (array) $children as $attachment ) {
$att_id = (int) ( $attachment->ID ?? 0 );
if ( $att_id <= 0 ) {
continue;
}
$role = (string) get_post_meta( $att_id, '_prautoblogger_image_role', true );
if ( '' === $role && $att_id !== $featured_id ) {
continue; // Not a pipeline attachment.
}
$url = (string) wp_get_attachment_image_url( $att_id, 'medium' );
if ( '' === $url ) {
continue;
}
$seen[ $att_id ] = true;
$images[] = array(
'id' => $att_id,
'role' => '' !== $role ? $role : 'featured',
'url' => $url,
'is_featured' => $att_id === $featured_id,
);
}
// Featured image attached elsewhere (not a child) still renders.
if ( $featured_id > 0 && ! isset( $seen[ $featured_id ] ) ) {
$url = (string) wp_get_attachment_image_url( $featured_id, 'medium' );
if ( '' !== $url ) {
$images[] = array(
'id' => $featured_id,
'role' => 'featured',
'url' => $url,
'is_featured' => true,
);
}
}
return $images;
}
}

View File

@@ -119,6 +119,42 @@ class PRAutoBlogger_Dossier_Page {
PRAUTOBLOGGER_VERSION,
true
);
// M3: edit + re-run layer (separate files; dossier.css is not grown).
wp_enqueue_style(
'prautoblogger-dossier-edit',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css',
array( 'prautoblogger-dossier' ),
PRAUTOBLOGGER_VERSION
);
wp_enqueue_script(
'prautoblogger-dossier-edit',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js',
array(),
PRAUTOBLOGGER_VERSION,
true
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config.
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
wp_localize_script(
'prautoblogger-dossier-edit',
'prabDossierEdit',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ),
'postId' => $post_id,
// Settings read at point of use -- same poll setting as the board.
'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ),
'strings' => array(
'saving' => __( 'Saving…', 'prautoblogger' ),
'error' => __( 'Request failed — try again.', 'prautoblogger' ),
'queued' => __( 'Queued…', 'prautoblogger' ),
'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ),
'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ),
'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ),
),
)
);
}
/**

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Builds the edit + re-run view-model layer for the dossier (v0.20.0, M3).
*
* What: Per-stage affordance data (editable? why not? latest fork,
* prefill messages from the fork or the stage's recorded
* request_json) plus the run-level spend strip (settled + reserved
* vs ceiling, warn fraction — guardrail 4's visible cost
* implication) and the overall eligibility verdict. Pure
* assembly: reads the rows the Dossier_Data_Assembler already
* fetched plus the stage_inputs store; performs NO mutations.
* Disabled affordances always carry an operator-readable reason —
* the UI never hides why an edit is unavailable.
* Who triggers it: PRAutoBlogger_Dossier_Data_Assembler::assemble().
* Dependencies: Rerun_Eligibility (policy), Stage_Input_Store (forks),
* Stage_Replay (body decode).
*
* @see admin/class-dossier-data-assembler.php — Caller.
* @see templates/admin/dossier-edit-panel.php — Renders this data.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Dossier_Rerun_Panel_Data {
/** Default warn threshold as a fraction of the per-run ceiling. */
private const DEFAULT_WARN_FRACTION = 0.8;
/**
* Run-level spend strip data (guardrail 4 visibility).
*
* @param array<string, mixed>|null $run Run ledger row, or null.
* @return array{settled: float, reserved: float, ceiling: float, fraction: float, warn: bool}
*/
public static function spend( ?array $run ): array {
$settled = null !== $run ? (float) ( $run['settled_usd'] ?? 0 ) : 0.0;
$reserved = null !== $run ? (float) ( $run['reserved_usd'] ?? 0 ) : 0.0;
$ceiling = null !== $run ? (float) ( $run['ceiling_usd'] ?? 0 ) : 0.0;
$fraction = $ceiling > 0 ? ( $settled + $reserved ) / $ceiling : 0.0;
/**
* Filters the spend fraction at which re-run confirms warn about
* approaching the per-run ceiling.
*
* @param float $warn_fraction Fraction of ceiling (default 0.8).
*/
$warn_fraction = (float) apply_filters( 'prautoblogger_rerun_ceiling_warn_fraction', self::DEFAULT_WARN_FRACTION );
return array(
'settled' => $settled,
'reserved' => $reserved,
'ceiling' => $ceiling,
'fraction' => $fraction,
'warn' => $ceiling > 0 && $fraction >= $warn_fraction,
);
}
/**
* Edit + re-run affordance data for one stage row.
*
* @param array<string, mixed> $stage_row run_stages row.
* @param array<int, array<string, mixed>> $log_rows generation_log rows for the stage.
* @param string $run_id Run UUID.
* @param array{ok: bool, reason: string} $eligibility Overall run eligibility.
* @return array<string, mixed> Affordance view model (see keys below).
*/
public static function for_stage( array $stage_row, array $log_rows, string $run_id, array $eligibility ): array {
$stage = (string) ( $stage_row['stage'] ?? '' );
$role = (string) ( $stage_row['agent_role'] ?? '' );
$item_key = (string) ( $stage_row['item_key'] ?? '' );
$data = array(
'stale' => ! empty( $stage_row['stale'] ),
'human_modified' => ! empty( $stage_row['human_modified'] ),
'attempt' => (int) ( $stage_row['attempt'] ?? 1 ),
'editable' => false,
'edit_reason' => '',
'fork' => null,
'prefill' => null,
'rerun_from' => $eligibility['ok'] && PRAutoBlogger_Rerun_Eligibility::is_chain_stage( $stage ),
);
if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ) {
$data['edit_reason'] = self::policy_reason( $stage );
return $data;
}
if ( ! $eligibility['ok'] ) {
$data['edit_reason'] = $eligibility['reason'];
return $data;
}
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $role, $item_key );
if ( null !== $fork ) {
$data['fork'] = array(
'version' => (int) $fork['version'],
'author' => (string) $fork['author'],
'created_at' => (string) $fork['created_at'],
'has_body' => ! empty( $fork['request_json'] ),
);
}
$base_json = ( null !== $fork && ! empty( $fork['request_json'] ) )
? (string) $fork['request_json']
: self::latest_request_json( $log_rows );
if ( null === $base_json ) {
$days = (int) get_option( 'prautoblogger_request_json_retention_days', PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS );
$data['edit_reason'] = sprintf(
/* translators: %d: retention days. */
__( 'No persisted input for this stage — request bodies are recorded from v0.20.0 onward and retained for %d days.', 'prautoblogger' ),
$days
);
return $data;
}
$body = PRAutoBlogger_Stage_Replay::decode_body( $base_json );
if ( null === $body ) {
$data['edit_reason'] = __( 'The persisted input for this stage is not a replayable chat request.', 'prautoblogger' );
return $data;
}
$data['editable'] = true;
$data['prefill'] = array(
'model' => (string) $body['model'],
'messages' => $body['messages'],
);
return $data;
}
/**
* Why a non-writer stage has no edit affordance (always shown —
* the UI never silently hides the option).
*
* @param string $stage Stage name.
* @return string Operator-readable reason.
*/
private static function policy_reason( string $stage ): string {
if ( 'review' === $stage ) {
return __( 'The review input is derived entirely from the article content — edit a writing stage, then use "Re-run from here" on this one.', 'prautoblogger' );
}
if ( 'publish' === $stage ) {
return __( 'Publish is not an LLM stage — use "Re-run from here" to refresh the post from current outputs.', 'prautoblogger' );
}
if ( in_array( $stage, array( 'analysis', 'research', 'llm_research' ), true ) ) {
return __( 'Run-level stage: its output fed idea selection for the whole run and cannot be re-flowed into a single article.', 'prautoblogger' );
}
if ( 0 === strpos( $stage, 'image' ) ) {
return __( 'Image stages are not replayable yet (Phase 2b restructure).', 'prautoblogger' );
}
return __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' );
}
/**
* The most recent recorded request body among a stage's log rows
* (success rows preferred, then latest id).
*
* @param array<int, array<string, mixed>> $log_rows generation_log rows.
* @return string|null
*/
private static function latest_request_json( array $log_rows ): ?string {
$best = null;
foreach ( $log_rows as $row ) {
if ( empty( $row['request_json'] ) ) {
continue;
}
if ( null === $best ) {
$best = $row;
continue;
}
$row_success = ( 'success' === ( $row['response_status'] ?? '' ) );
$best_success = ( 'success' === ( $best['response_status'] ?? '' ) );
if ( ( $row_success && ! $best_success )
|| ( $row_success === $best_success && (int) ( $row['id'] ?? 0 ) > (int) ( $best['id'] ?? 0 ) ) ) {
$best = $row;
}
}
return null !== $best ? (string) $best['request_json'] : null;
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.2.0).
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.3.0).
*
* What: Owns the five v0.18.0 substrate tables — the versioned prompt
* What: Owns the six pipeline substrate tables — the versioned prompt
* registry (`prompts`), the audit child tables (`run_sources`,
* `run_decisions`), and the run ledger / state machine tables
* (`runs`, `run_stages`). Kept separate from the original
* (`runs`, `run_stages`), and the immutable stage-input version
* store (`stage_inputs`, v0.20.0 / db 1.3.0 — edit + re-run forks
* and idea seeds). Kept separate from the original
* PRAutoBlogger_Schema_Installer so both classes stay under the
* 300-line cap and the v1.1.0 schema remains byte-stable.
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
@@ -33,7 +35,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
* plugin's forward-only schema policy. Safe to re-run on every
* version mismatch.
*
* Side effects: up to five CREATE TABLE / ALTER TABLE statements.
* Side effects: up to six CREATE TABLE / ALTER TABLE statements.
*
* @return void
*/
@@ -91,6 +93,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
verdict VARCHAR(50) NOT NULL,
rationale TEXT DEFAULT NULL,
citation_score FLOAT DEFAULT NULL,
human_modified TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY run_id (run_id)
@@ -130,6 +133,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
meta_json LONGTEXT DEFAULT NULL,
human_modified TINYINT(1) NOT NULL DEFAULT 0,
stale TINYINT(1) NOT NULL DEFAULT 0,
started_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
@@ -138,10 +143,37 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
KEY status_updated (status, updated_at)
) {$charset_collate};";
// Immutable stage-input version store (v0.20.0 / db 1.3.0, M3
// edit + re-run). Rows are INSERT-only: a human edit creates the
// next (scope, version) row -- the original input (the
// generation_log.request_json of the executed call) is never
// overwritten (CPO guardrail 1). source='seed' rows persist the
// Article_Idea payload at worker start so re-run-from-here can
// reconstruct the exact idea (item_key hash stability); 'human'
// rows are edit forks. Human fork bodies are retention-pruned
// like all heavy payloads; seed rows (~1KB) are structural and
// kept for the life of the run.
$sql_stage_inputs = "CREATE TABLE {$prefix}stage_inputs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
stage VARCHAR(50) NOT NULL DEFAULT '',
agent_role VARCHAR(50) NOT NULL DEFAULT '',
item_key VARCHAR(64) NOT NULL DEFAULT '',
version INT UNSIGNED NOT NULL DEFAULT 1,
source VARCHAR(20) NOT NULL DEFAULT 'human',
request_json LONGTEXT DEFAULT NULL,
author VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY scope_version (run_id, stage, agent_role, item_key, version),
KEY run_id (run_id)
) {$charset_collate};";
dbDelta( $sql_prompts );
dbDelta( $sql_run_sources );
dbDelta( $sql_run_decisions );
dbDelta( $sql_runs );
dbDelta( $sql_run_stages );
dbDelta( $sql_stage_inputs );
}
}

View File

@@ -141,6 +141,11 @@ class PRAutoBlogger {
new PRAutoBlogger_Opik_Settings();
}
// v0.20.0 (M3): operator re-run jobs — chained-cron, never synchronous.
$rerun_executor = new PRAutoBlogger_Rerun_Executor();
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
@@ -174,6 +179,13 @@ class PRAutoBlogger {
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
// v0.20.0 (M3): dossier edit + re-run endpoints (validate + queue only).
$dossier_actions = new PRAutoBlogger_Dossier_Actions();
add_action( 'wp_ajax_prautoblogger_dossier_save_input', array( $dossier_actions, 'on_save_input' ) );
add_action( 'wp_ajax_prautoblogger_dossier_rerun_stage', array( $dossier_actions, 'on_rerun_stage' ) );
add_action( 'wp_ajax_prautoblogger_dossier_rerun_from', array( $dossier_actions, 'on_rerun_from' ) );
add_action( 'wp_ajax_prautoblogger_dossier_stage_status', array( $dossier_actions, 'on_stage_status' ) );
$ideas = new PRAutoBlogger_Ideas_Browser();
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );

View File

@@ -58,6 +58,13 @@ class PRAutoBlogger_Article_Worker {
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
$item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
// v0.20.0: persist the EXACT idea payload once per item so
// re-run-from-here can reconstruct it (rebuilding from post
// fields would risk an item_key hash mismatch -> duplicate post).
if ( '' !== $run_id ) {
PRAutoBlogger_Stage_Input_Store::save_seed( $run_id, $item, (string) wp_json_encode( $idea->to_array() ) );
}
$result = array(
'generated' => 0,
'published' => 0,

View File

@@ -125,6 +125,37 @@ class PRAutoBlogger_Audit_Writer {
}
}
/**
* Flag the run_decisions rows of given stages as human-modified
* (v0.20.0 / CPO guardrail 2). Used in two cases: the decision rows of
* a stage whose edited input enters execution, and decisions recorded
* during a re-run-from-here window on an item that carries a human
* edit (derived-from-edited-content). Self-healing no-op when the
* column/table is missing.
*
* @param string $run_id Run UUID.
* @param array<string> $stages Stage names to flag.
* @param string|null $since Optional MySQL datetime — only flag rows created at/after it.
* @return int Rows flagged.
*/
public static function flag_decisions_human_modified( string $run_id, array $stages, ?string $since = null ): int {
if ( '' === $run_id || empty( $stages ) || ! self::is_available() ) {
return 0;
}
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
$sql = "UPDATE {$table} SET human_modified = 1 WHERE run_id = %s AND stage IN ({$placeholders})";
$params = array_merge( array( $run_id ), array_values( $stages ) );
if ( null !== $since ) {
$sql .= ' AND created_at >= %s';
$params[] = $since;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$updated = $wpdb->query( $wpdb->prepare( $sql, $params ) );
return is_numeric( $updated ) ? (int) $updated : 0;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$tables_ok = null;

View File

@@ -69,7 +69,9 @@ class PRAutoBlogger_Cost_Tracker {
/**
* Log an API call with its token usage and estimated cost.
*
* Side effects: database insert into prab_generation_log.
* Side effects: database insert into prab_generation_log. Consumes the
* Request_Recorder stash (v0.20.0 / B1) into the row's request_json so
* every chat call's input is persisted (retention-pruned after R days).
*
* @param int|null $post_id WordPress post ID (null during generation, set later).
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
@@ -117,6 +119,7 @@ class PRAutoBlogger_Cost_Tracker {
'estimated_cost' => $cost,
'response_status' => $response_status,
'error_message' => $error_message,
'request_json' => PRAutoBlogger_Request_Recorder::consume(),
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
'created_at' => current_time( 'mysql' ),

View File

@@ -107,11 +107,11 @@ class PRAutoBlogger_Publisher {
if ( null !== $run_id && '' !== $run_id ) {
$existing_id = $this->find_existing_post( $run_id, $idea_hash );
if ( $existing_id > 0 ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ),
'publisher'
);
return $existing_id;
// v0.20.0: a re-run's regenerated content must land on the
// existing UNPUBLISHED post (otherwise re-runs silently
// discard their output). Published posts stay frozen
// (CPO guardrail 5) — refresh skips them untouched.
return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review );
}
}
@@ -157,6 +157,60 @@ class PRAutoBlogger_Publisher {
return $post_id;
}
/**
* Refresh an existing post from re-run output — unpublished only.
*
* Idempotent-resume calls land here too (same content in = same
* content out, so behavior is unchanged for plain resumes). The post
* keeps its CURRENT status: a re-run never publishes an unpublished
* post — publication stays an explicit Review Queue / WP editor
* action. Published (and scheduled/private) posts are returned
* untouched: the run is frozen (CPO guardrail 5).
*
* Side effects: wp_update_post + post meta updates (unpublished only).
*
* @param int $post_id Existing post ID.
* @param string $content Regenerated HTML content.
* @param PRAutoBlogger_Article_Idea $idea The idea.
* @param PRAutoBlogger_Editorial_Review $review The fresh review.
* @return int The post ID.
*/
private function refresh_unpublished_post( int $post_id, string $content, PRAutoBlogger_Article_Idea $idea, PRAutoBlogger_Editorial_Review $review ): int {
$post = get_post( $post_id );
if ( PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ) ) {
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Post %d already exists and is published — frozen, skipping content refresh.', $post_id ),
'publisher'
);
return $post_id;
}
// Same sanitize chain as create_post().
$clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content );
$linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content );
wp_update_post(
array(
'ID' => $post_id,
'post_title' => sanitize_text_field( $idea->get_suggested_title() ),
'post_content' => wp_kses_post( $linked_content ),
)
);
update_post_meta( $post_id, '_prautoblogger_editor_verdict', $review->get_verdict() );
update_post_meta( $post_id, '_prautoblogger_editor_notes', $review->get_notes() );
update_post_meta( $post_id, '_prautoblogger_quality_score', $review->get_quality_score() );
update_post_meta( $post_id, '_prautoblogger_seo_score', $review->get_seo_score() );
update_post_meta( $post_id, '_prautoblogger_generated_at', gmdate( 'c' ) );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Post %d refreshed from re-run output (status preserved: %s).', $post_id, (string) ( $post->post_status ?? '' ) ),
'publisher'
);
return $post_id;
}
/**
* Build post_meta array for generation metadata.
*

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-PHP-process stash for the most recent outgoing LLM request body.
*
* What: A process-scoped static slot recording the JSON request body the
* OpenRouter provider is about to dispatch, so Cost_Tracker::
* log_api_call() can persist it to `generation_log.request_json`
* (v0.20.0 / B1) without threading the body through every call
* site. Mirrors the Run_Context precedent (v0.18.0).
*
* SECURITY GUARANTEE: only the request BODY is ever recorded.
* Authorization headers are built separately
* (Request_Builder::build_headers()) and can never reach this
* class; build_body() copies only whitelisted option keys.
*
* Consume-once semantics: consume() clears the slot, and record()
* overwrites it at every dispatch — so a stale body from an
* unlogged failed call can never attach to a later, unrelated
* log row.
* Who triggers it: OpenRouter_Provider::send_chat_completion() (record,
* pre-dispatch so error rows carry the request too);
* Cost_Tracker::log_api_call() (consume).
* Dependencies: none.
*
* @see providers/class-open-router-provider.php — Records before dispatch.
* @see core/class-cost-tracker.php — Consumes into the log row.
* @see core/class-run-context.php — The pattern this mirrors.
* @see ARCHITECTURE.md #24 — Edit + re-run substrate (B1).
*/
class PRAutoBlogger_Request_Recorder {
/** @var string|null JSON-encoded request body of the in-flight LLM call. */
private static ?string $request_json = null;
/**
* Record the outgoing request body for the next log row.
*
* Overwrites any previous stash (one in-flight chat call per PHP
* process). Bodies that fail JSON encoding are skipped silently —
* the log row's request_json stays NULL, never blocking the call.
*
* @param array<string, mixed> $body Request body from Request_Builder::build_body().
* @return void
*/
public static function record( array $body ): void {
$encoded = wp_json_encode( $body );
self::$request_json = is_string( $encoded ) ? $encoded : null;
}
/**
* Return the stashed request body and clear the slot (consume-once).
*
* @return string|null JSON request body, or null when nothing is stashed.
*/
public static function consume(): ?string {
$json = self::$request_json;
self::$request_json = null;
return $json;
}
/**
* Clear the stash (tests / defensive teardown).
*
* @return void
*/
public static function clear(): void {
self::$request_json = null;
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Policy gates for edit + single-step re-run (v0.20.0, M3).
*
* What: Single source of truth for WHO may re-run WHAT:
* - Published posts are frozen (CPO guardrail 5): publish/future/
* private statuses lock the run; edits go through the WP editor.
* - Runs that are actively executing (status pending/running or
* the global generation lock held) cannot be re-run — "in
* progress" in the CPO sense means in the WORKFLOW (unpublished),
* not mid-execution.
* - Editable stages (input fork + replay) are the item-scoped
* writer chat stages of the CURRENT pipeline vocabulary:
* outline/draft/polish (single-pass uses draft only). review's
* input is fully derived from content (re-run it from here
* instead); analysis/research are run-level idea-selection
* stages; image stages are not chat-replayable (Phase 2b).
* - The canonical per-item chain orders downstream computation
* for stale-marking and rebuild sets.
* Who triggers it: Dossier_Actions (UI affordances + request validation),
* Rerun_Executor (re-validation under the lock before mutating).
* Dependencies: get_post(), Run_State, Generation_Lock, Stage_Input_Store.
*
* @see admin/class-dossier-actions.php — AJAX validation.
* @see core/class-rerun-executor.php — Cron-side re-validation.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Rerun_Eligibility {
/** Post statuses that freeze a run (CPO guardrail 5). */
public const FROZEN_STATUSES = array( 'publish', 'future', 'private' );
/** Stages whose input may be edited and replayed (chat writer stages). */
public const EDITABLE_STAGES = array( 'outline', 'draft', 'polish' );
/** Canonical per-item stage chain (current pipeline vocabulary). */
public const ITEM_STAGE_CHAIN = array( 'outline', 'draft', 'polish', 'review', 'publish' );
/**
* Whether a stage's input may be edited + replayed.
*
* @param string $stage Stage name.
* @return bool
*/
public static function is_editable_stage( string $stage ): bool {
return in_array( $stage, self::EDITABLE_STAGES, true );
}
/**
* Whether re-run-from-here may target this stage (any chain stage).
*
* @param string $stage Stage name.
* @return bool
*/
public static function is_chain_stage( string $stage ): bool {
return in_array( $stage, self::ITEM_STAGE_CHAIN, true );
}
/**
* Stages strictly AFTER a stage in the canonical chain — the set that
* goes stale when the stage is re-run. Stages absent from an item
* (e.g. outline/polish on single-pass runs) simply match no rows.
*
* @param string $stage Stage name.
* @return array<string> Downstream stage names ([] for non-chain stages).
*/
public static function downstream_of( string $stage ): array {
$idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true );
if ( false === $idx ) {
return array();
}
return array_slice( self::ITEM_STAGE_CHAIN, $idx + 1 );
}
/**
* The demotion set for re-run-from-here: the stage itself plus all
* downstream chain stages.
*
* @param string $stage Stage name.
* @return array<string>
*/
public static function rebuild_set( string $stage ): array {
$idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true );
if ( false === $idx ) {
return array();
}
return array_slice( self::ITEM_STAGE_CHAIN, $idx );
}
/**
* Whether a post is frozen for pipeline re-runs (guardrail 5).
*
* @param WP_Post|null $post The post, or null when none exists yet.
* @return bool
*/
public static function post_frozen( ?WP_Post $post ): bool {
return ( $post instanceof WP_Post )
&& in_array( (string) $post->post_status, self::FROZEN_STATUSES, true );
}
/**
* Full re-run eligibility check for a (run, post) pair.
*
* Returns ok=false with an operator-readable reason for: missing/
* frozen post, missing run row, run actively executing, or the
* global generation lock being held. Called from the AJAX layer for
* UI affordances AND re-validated by the cron handler under the lock
* (state can change between click and pickup).
*
* @param string $run_id Run UUID.
* @param int $post_id Post ID (0 = run has no post yet).
* @param bool $require_idle_lock Whether a held generation lock blocks (true for actions).
* @return array{ok: bool, reason: string} Verdict + reason ('' when ok).
*/
public static function check( string $run_id, int $post_id, bool $require_idle_lock = true ): array {
if ( '' === $run_id ) {
return self::no( __( 'This article has no recorded generation run.', 'prautoblogger' ) );
}
$post = $post_id > 0 ? get_post( $post_id ) : null;
if ( self::post_frozen( $post ) ) {
return self::no( __( 'This article is published — its run is frozen. Edit the article in the WordPress editor instead.', 'prautoblogger' ) );
}
$run = PRAutoBlogger_Run_State::get_run( $run_id );
if ( null === $run ) {
return self::no( __( 'No run ledger row exists for this article (pre-substrate run).', 'prautoblogger' ) );
}
if ( in_array( (string) $run['status'], array( 'pending', 'running' ), true ) ) {
return self::no( __( 'This run is currently executing. Wait for it to finish (or for the reaper to mark it failed).', 'prautoblogger' ) );
}
if ( $require_idle_lock && null !== PRAutoBlogger_Generation_Lock::get_acquired_at() ) {
return self::no( __( 'Another generation is in progress (lock held). Try again when it completes.', 'prautoblogger' ) );
}
return array(
'ok' => true,
'reason' => '',
);
}
/**
* Replay-specific check: base eligibility + editable stage + a saved
* input fork whose body has not been retention-pruned.
*
* @param string $run_id Run UUID.
* @param int $post_id Post ID.
* @param string $stage Stage name.
* @param string $agent_role Stage row agent role.
* @param string $item_key Stage row item key.
* @param bool $require_idle_lock False when the caller already owns the lock (cron handler).
* @return array{ok: bool, reason: string}
*/
public static function check_replay( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key, bool $require_idle_lock = true ): array {
if ( ! self::is_editable_stage( $stage ) ) {
return self::no( __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) );
}
$base = self::check( $run_id, $post_id, $require_idle_lock );
if ( ! $base['ok'] ) {
return $base;
}
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
if ( null === $fork ) {
return self::no( __( 'Save an edited input for this stage first — re-run executes your latest saved version.', 'prautoblogger' ) );
}
if ( empty( $fork['request_json'] ) ) {
return self::no( __( 'The saved input for this stage was pruned by the retention cron and can no longer be replayed.', 'prautoblogger' ) );
}
return $base;
}
/**
* Rebuild-specific check: base eligibility + chain stage + idea seed
* available (runs started before v0.20.0 have no seed).
*
* @param string $run_id Run UUID.
* @param int $post_id Post ID.
* @param string $stage Target stage.
* @param string $item_key Item key.
* @param bool $require_idle_lock False when the caller already owns the lock (cron handler).
* @return array{ok: bool, reason: string}
*/
public static function check_rebuild( string $run_id, int $post_id, string $stage, string $item_key, bool $require_idle_lock = true ): array {
if ( ! self::is_chain_stage( $stage ) ) {
return self::no( __( 'Re-run from here is only available on article pipeline stages.', 'prautoblogger' ) );
}
$base = self::check( $run_id, $post_id, $require_idle_lock );
if ( ! $base['ok'] ) {
return $base;
}
if ( null === PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ) ) {
return self::no( __( 'No idea seed is stored for this run (recorded from v0.20.0 onward) — the pipeline cannot rebuild it safely.', 'prautoblogger' ) );
}
return $base;
}
/**
* Shorthand for a negative verdict.
*
* @param string $reason Operator-readable reason.
* @return array{ok: bool, reason: string}
*/
private static function no( string $reason ): array {
return array(
'ok' => false,
'reason' => $reason,
);
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Queues and executes operator re-runs as chained cron jobs (v0.20.0, M3).
*
* What: The chained-cron seam for the dossier's two re-run actions —
* NEVER synchronous (CPO hard constraint): the AJAX layer only
* schedules; ALL state mutations happen in the cron handler after
* re-validating eligibility under the global generation lock.
* - Replay job: single-stage re-run of the latest edited input
* fork (Stage_Replay). Demotes the one stage row to running
* (human_modified=1), stale-marks downstream, executes, restores
* a terminal run status.
* - Rebuild job ("re-run from here"): demotes the target + all
* downstream chain stages to pending, then re-enters
* Article_Worker with the idea reconstructed from the stored
* seed — upstream done stages are reused (never re-charged),
* demoted stages re-run with prompts rebuilt from CURRENT
* upstream snapshots, and the publisher refreshes the
* unpublished post.
* Both jobs: monthly budget check, Run_State::reopen() (ceiling
* re-snapshotted from the current setting — a deliberate new-spend
* decision), M1 status transient for board/dossier pickup
* visibility, lock released in finally. Failures restore/record a
* terminal run status so the run-reaper's stuck-running sweep
* stays meaningful.
* Who triggers it: Dossier_Actions (queue_*), WP-Cron (on_* handlers via
* class-prautoblogger.php registration).
* Dependencies: Rerun_Job_Support (lock/budget/transient plumbing),
* Run_State, Run_Stage_Rerun_State, Stage_Input_Store,
* Stage_Replay, Article_Worker, Rerun_Eligibility.
*
* @see admin/class-dossier-actions.php — The AJAX layer (queues via Job_Support).
* @see core/class-rerun-job-support.php — Shared queue/lock/status plumbing.
* @see core/class-stage-replay.php — The governed replay call.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Rerun_Executor {
/** Cron action for single-stage replay jobs. */
public const REPLAY_ACTION = 'prautoblogger_rerun_stage_replay';
/** Cron action for re-run-from-here rebuild jobs. */
public const REBUILD_ACTION = 'prautoblogger_rerun_from_stage';
/**
* Cron handler: replay one stage from its latest edited input fork.
*
* Side effects: run_stages/runs mutations, one governed LLM call,
* generation_log row, status transient, lock acquire/release.
*
* @param string $run_id Run UUID.
* @param int $post_id Post the dossier acted on.
* @param string $stage Stage name.
* @param string $agent_role Stage row agent role.
* @param string $item_key Stage row item key.
* @return void
*/
public function on_replay_job( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key ): void {
PRAutoBlogger_Rerun_Job_Support::harden_process();
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
return;
}
$previous_status = null;
try {
// Re-validate under the lock — state may have changed between
// click and pickup (e.g. the post was published).
// require_idle_lock=false: THIS handler owns the lock.
$check = PRAutoBlogger_Rerun_Eligibility::check_replay( $run_id, $post_id, $stage, $agent_role, $item_key, false );
if ( ! $check['ok'] ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] );
return;
}
if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) );
return;
}
$fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key );
$body = null !== $fork ? PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] ) : null;
if ( null === $body ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The saved input could not be decoded as a replayable request.', 'prautoblogger' ) );
return;
}
$previous_status = PRAutoBlogger_Run_State::get_status( $run_id );
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
return;
}
if ( ! PRAutoBlogger_Run_Stage_Rerun_State::restart( $run_id, $stage, $agent_role, $item_key, true ) ) {
PRAutoBlogger_Rerun_Job_Support::restore_terminal( $run_id, $previous_status );
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stage row could not be demoted for replay (it may be executing).', 'prautoblogger' ) );
return;
}
// Guardrail 3: explicit, visible downstream invalidation.
PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( $run_id, $item_key, PRAutoBlogger_Rerun_Eligibility::downstream_of( $stage ) );
PRAutoBlogger_Rerun_Job_Support::broadcast(
sprintf(
/* translators: %s: stage display label. */
__( 'Re-running %s with the edited input…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $stage )
)
);
$result = PRAutoBlogger_Stage_Replay::run( $run_id, $stage, $agent_role, $item_key, $body );
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
PRAutoBlogger_Rerun_Job_Support::finish_complete( $result['cost'] );
} catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) {
// Governor already halted the run + recorded the overage.
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
} catch ( \Throwable $e ) {
PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key );
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Stage replay %s for run %s/%s: %s', get_class( $e ), $run_id, $stage, $e->getMessage() ),
'rerun'
);
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
/**
* Cron handler: re-run from a stage — demote it + downstream, then
* re-enter the worker so demoted stages rebuild from current
* upstream snapshots.
*
* Side effects: run_stages/runs mutations, worker LLM calls
* (governed), post refresh, status transient, lock acquire/release.
*
* @param string $run_id Run UUID.
* @param int $post_id Post the dossier acted on.
* @param string $stage Target stage (start of the rebuild set).
* @param string $item_key Item key.
* @return void
*/
public function on_rebuild_job( string $run_id, int $post_id, string $stage, string $item_key ): void {
PRAutoBlogger_Rerun_Job_Support::harden_process();
if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) {
return;
}
try {
// require_idle_lock=false: THIS handler owns the lock.
$check = PRAutoBlogger_Rerun_Eligibility::check_rebuild( $run_id, $post_id, $stage, $item_key, false );
if ( ! $check['ok'] ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] );
return;
}
if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) );
return;
}
$idea_json = PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key );
$idea_data = null !== $idea_json ? json_decode( $idea_json, true ) : null;
if ( ! is_array( $idea_data ) ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stored idea seed could not be decoded.', 'prautoblogger' ) );
return;
}
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$job_start = current_time( 'mysql' );
$demoted = PRAutoBlogger_Rerun_Eligibility::rebuild_set( $stage );
if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) );
return;
}
PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( $run_id, $item_key, $demoted );
PRAutoBlogger_Rerun_Job_Support::broadcast(
sprintf(
/* translators: %s: stage display label. */
__( 'Re-running the pipeline from %s…', 'prautoblogger' ),
PRAutoBlogger_Stage_Display_Map::label( $stage )
)
);
$tracker = new PRAutoBlogger_Cost_Tracker();
$tracker->set_run_id( $run_id );
$result = ( new PRAutoBlogger_Article_Worker( $tracker ) )->generate( $idea );
// The worker traps its own failures (fail_open_for_item);
// publish-done is the success signal.
$succeeded = PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item_key );
// Decisions recorded while rebuilding a human-modified item
// derive from edited content — flag them (guardrail 2).
if ( PRAutoBlogger_Run_Stage_Rerun_State::item_has_human_modified( $run_id, $item_key ) ) {
PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, $demoted, $job_start );
}
PRAutoBlogger_Run_State::mark_status( $run_id, $succeeded ? 'done' : 'failed' );
if ( $succeeded ) {
PRAutoBlogger_Rerun_Job_Support::finish_complete( (float) $result['cost'] );
} else {
PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The re-run did not complete — check the Activity Log and the dossier stage states.', 'prautoblogger' ) );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Rebuild from %s %s for run %s: %s', $stage, get_class( $e ), $run_id, $e->getMessage() ),
'rerun'
);
PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() );
} finally {
PRAutoBlogger_Generation_Lock::release();
}
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Shared plumbing for re-run cron jobs (v0.20.0, M3).
*
* What: The queue/lock/budget/status-transient helpers both
* Rerun_Executor handlers share — extracted so the executor stays
* under the 300-line cap. The status transient writes use the M1
* Generation_Status_Poller shape so the kanban board (and the
* dossier's poll) show queued → pickup → result without any new
* polling infrastructure; the poller's existing R2/R3 orphan
* recovery covers a dead re-run process unchanged.
* Who triggers it: PRAutoBlogger_Rerun_Executor (handlers) and
* PRAutoBlogger_Dossier_Actions (queue()).
* Dependencies: Generation_Lock, Generation_Status_Poller (constants),
* Cost_Tracker, Run_State, Logger, WP-Cron.
*
* @see core/class-rerun-executor.php — The job handlers.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Rerun_Job_Support {
/**
* Schedule a re-run job and fire the cron spawner (non-blocking).
*
* The uniq argument keeps wp_schedule_single_event()'s identical-
* args dedup from swallowing a legitimate second click after a
* failed attempt within the 10-minute window.
*
* Side effects: one single cron event, status transient write,
* non-blocking loopback request.
*
* @param string $action Cron action name.
* @param array $args Handler args.
* @param string $queued_message Operator-facing "queued" copy.
* @return void
*/
public static function queue( string $action, array $args, string $queued_message ): void {
$args[] = (string) microtime( true ); // Uniqueness token.
wp_schedule_single_event( time(), $action, $args );
self::broadcast( $queued_message );
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
);
}
/** Raise process limits for a background LLM job (cron context). */
public static function harden_process(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
}
/**
* Acquire the global generation mutex or report the conflict.
*
* @return bool Whether the lock was acquired.
*/
public static function acquire_lock_or_report(): bool {
if ( PRAutoBlogger_Generation_Lock::acquire() ) {
return true;
}
self::finish_error( __( 'Could not acquire the generation lock — another run is in progress. Re-run again when it completes.', 'prautoblogger' ) );
return false;
}
/**
* Whether the monthly budget hard-stop blocks new spend.
*
* @return bool
*/
public static function monthly_budget_exceeded(): bool {
return ( new PRAutoBlogger_Cost_Tracker() )->is_budget_exceeded();
}
/**
* Restore a terminal run status after an aborted-but-reopened job.
*
* @param string $run_id Run UUID.
* @param string|null $status The pre-reopen status (done/failed/halted).
* @return void
*/
public static function restore_terminal( string $run_id, ?string $status ): void {
if ( null !== $status && in_array( $status, array( 'done', 'failed', 'halted' ), true ) ) {
PRAutoBlogger_Run_State::mark_status( $run_id, $status );
}
}
/**
* Update the live status transient (board + dossier polling).
*
* @param string $message Stage message.
* @return void
*/
public static function broadcast( string $message ): void {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'running',
'stage' => $message,
'started' => time(),
'last_updated' => time(),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
/**
* Final transient: success (board-compatible shape).
*
* @param float $cost Job spend in USD.
* @return void
*/
public static function finish_complete( float $cost ): void {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'complete',
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => $cost,
'message' => __( 'Re-run complete.', 'prautoblogger' ),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
/**
* Final transient: error (board-compatible shape) + log.
*
* @param string $message Operator-facing error.
* @return void
*/
public static function finish_error( string $message ): void {
PRAutoBlogger_Logger::instance()->warning( 'Re-run job: ' . $message, 'rerun' );
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $message,
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
}
}

View File

@@ -16,8 +16,9 @@ declare(strict_types=1);
* ledger/state activity for 2× the expected run wall-clock are
* marked `failed` and their non-done stages reaped to `failed`;
* such runs render as "incomplete" via incomplete_runs().
* 3. Retention — `request_json` on generation_log and stage output
* 3. Retention — `request_json` on generation_log, stage output
* payloads (`meta_json` on run_stages) are NULLed after R days;
* payloads, and stage_inputs human-fork bodies (v0.20.0);
* R comes from the `prautoblogger_request_json_retention_days`
* SETTING (default constant 14; 0 = keep forever) — never a
* literal at the point of use.
@@ -265,6 +266,11 @@ class PRAutoBlogger_Run_Reaper {
$pruned += is_numeric( $result ) ? (int) $result : 0;
}
// v0.20.0: human edit-fork bodies ride the same retention (seed
// rows are kept — structural, ~1KB). A pruned fork can no longer
// be replayed; the dossier explains that state honestly.
$pruned += PRAutoBlogger_Stage_Input_Store::prune_human_bodies( $cutoff );
return $pruned;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Operator-action state mutations for edit + re-run (v0.20.0, M3).
*
* What: The ONLY paths that may demote a 'done' run_stages row — and all
* of them are deliberate operator actions dispatched from the
* dossier (never reachable from pipeline resume code):
* - restart(): done/failed/halted/pending → running for a single-
* stage replay; bumps attempt, clears stale, sets human_modified
* when the replay uses an edited input fork (CPO guardrail 2).
* - mark_stale(): flags downstream 'done' stages as stale after an
* upstream edit enters execution. Stale is a COLUMN, not a
* status: the stage stays 'done', so resume-without-recharge
* logic can never silently re-run it (CPO guardrail 3 holds by
* construction). Only Run_Stage_Writes::done() clears it.
* - demote_to_pending(): re-run-from-here demotion; the worker
* re-entry then rebuilds those stages from current upstream
* snapshots. Audit columns (attempt, human_modified) survive;
* old meta_json survives until overwritten by the fresh done().
* Every method no-ops when the run_stages table is missing.
* Who triggers it: PRAutoBlogger_Rerun_Executor (cron handlers, after
* re-validating eligibility under the generation lock).
* Dependencies: Run_Stage_State (probe/table), $wpdb.
*
* @see core/class-rerun-executor.php — Sole caller.
* @see core/class-run-stage-writes.php — Pipeline-path writes (done clears stale).
* @see core/class-rerun-eligibility.php — Policy gates before any mutation.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Run_Stage_Rerun_State {
/**
* Demote one stage row to 'running' for an operator replay.
*
* Allowed from done/failed/halted/pending — never from 'running'
* (a concurrently executing stage cannot be restarted). Bumps the
* attempt counter, clears `stale` (the row is being refreshed) and
* sets `human_modified` when the replay executes an edited fork.
* `human_modified` is never cleared by any code path — it is sticky
* audit state for the life of the run.
*
* Side effects: one UPDATE on run_stages.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Agent role (resolved by the caller from the actual row).
* @param string $item_key Item key.
* @param bool $human_modified Whether an edited input fork drives this replay.
* @return bool Whether a row was demoted.
*/
public static function restart( string $run_id, string $stage, string $agent_role, string $item_key, bool $human_modified ): bool {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return false;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET status = 'running', attempt = attempt + 1, stale = 0,
human_modified = IF(%d = 1, 1, human_modified),
updated_at = %s, finished_at = NULL
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s
AND status IN ('done','failed','halted','pending')",
$human_modified ? 1 : 0,
$now,
$run_id,
$stage,
$agent_role,
$item_key
)
);
return 1 === (int) $updated;
}
/**
* Flag a set of 'done' stages as stale (explicit downstream
* invalidation after an upstream edit/re-run — CPO guardrail 3).
*
* Nothing is demoted and nothing auto-re-runs: the rows stay 'done'
* and visible; the operator triggers each subsequent stage (or
* re-run-from-here) deliberately.
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @param array<string> $stages Stage names to flag.
* @return int Rows flagged.
*/
public static function mark_stale( string $run_id, string $item_key, array $stages ): int {
if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return 0;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
$params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET stale = 1, updated_at = %s
WHERE run_id = %s AND item_key = %s AND status = 'done' AND stage IN ({$placeholders})",
$params
)
);
return is_numeric( $updated ) ? (int) $updated : 0;
}
/**
* Demote a set of stages to 'pending' for re-run-from-here.
*
* The pipeline's resume machinery treats pending stages as not-done
* and re-executes them with prompts REBUILT from current upstream
* snapshots — that is how an upstream edit propagates downstream.
* Rows keep attempt/human_modified (audit) and their old meta_json
* until the fresh completion overwrites it; `stale` stays 1 until
* Run_Stage_Writes::done() clears it on fresh output. Never touches
* a 'running' row.
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @param array<string> $stages Stage names to demote.
* @return int Rows demoted.
*/
public static function demote_to_pending( string $run_id, string $item_key, array $stages ): int {
if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return 0;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) );
$params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'pending', stale = 1, updated_at = %s, finished_at = NULL
WHERE run_id = %s AND item_key = %s AND status != 'running' AND stage IN ({$placeholders})",
$params
)
);
return is_numeric( $updated ) ? (int) $updated : 0;
}
/**
* Whether any stage row of this item carries the human_modified flag
* (drives derived-decision flagging during rebuilds — guardrail 2).
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @return bool
*/
public static function item_has_human_modified( string $run_id, string $item_key ): bool {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return false;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_var(
$wpdb->prepare(
"SELECT 1 FROM {$table} WHERE run_id = %s AND item_key = %s AND human_modified = 1 LIMIT 1",
$run_id,
$item_key
)
);
return null !== $found;
}
}

View File

@@ -16,9 +16,17 @@ declare(strict_types=1);
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role=''
* on miss for mid-run-upgrade compat with v0.18.1 rows.
*
* v0.20.0: write bodies extracted to PRAutoBlogger_Run_Stage_Writes (the
* M2 split pattern — this class was at the 300-line cap); the public API
* here is unchanged and delegates. Operator-action mutations (replay
* restart, stale marking, re-run demotion) live in
* PRAutoBlogger_Run_Stage_Rerun_State only.
*
* @see core/class-run-stage-writes.php — Extracted write bodies.
* @see core/class-run-stage-rerun-state.php — M3 operator-action writes.
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
* @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate.
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
*/
class PRAutoBlogger_Run_Stage_State {
@@ -73,6 +81,7 @@ class PRAutoBlogger_Run_Stage_State {
/**
* Mark a stage as running. Sticky: done stages are never demoted.
* Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name (see Stage_Display_Map).
@@ -80,35 +89,13 @@ class PRAutoBlogger_Run_Stage_State {
* @param string $item_key Article scope ('' for run-level stages).
*/
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
ON DUPLICATE KEY UPDATE
attempt = IF(status = 'done', attempt, attempt + 1),
status = IF(status = 'done', 'done', 'running'),
updated_at = VALUES(updated_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$now,
$now
)
);
PRAutoBlogger_Run_Stage_Writes::start( $run_id, $stage, $agent_role, $item_key );
}
/**
* Mark a stage done and persist its output for resume-without-recharge.
* Delegates to Run_Stage_Writes (v0.20.0 split; fresh completion also
* clears the `stale` flag there).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
@@ -118,41 +105,12 @@ class PRAutoBlogger_Run_Stage_State {
* @param float $cost_usd Cost attributed to this stage.
*/
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
status = 'done',
cost_usd = VALUES(cost_usd),
meta_json = VALUES(meta_json),
updated_at = VALUES(updated_at),
finished_at = VALUES(finished_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$cost_usd,
$meta,
$now,
$now,
$now
)
);
PRAutoBlogger_Run_Stage_Writes::done( $run_id, $stage, $agent_role, $item_key, $output, $cost_usd );
}
/**
* Mark a stage failed (does not demote a done stage).
* Mark a stage failed (does not demote a done stage). Delegates to
* Run_Stage_Writes (v0.20.0 split — behavior unchanged).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
@@ -160,26 +118,7 @@ class PRAutoBlogger_Run_Stage_State {
* @param string $item_key Article scope.
*/
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
$now,
$now,
$run_id,
$stage,
$agent_role,
$item_key
)
);
PRAutoBlogger_Run_Stage_Writes::fail( $run_id, $stage, $agent_role, $item_key );
}
/**
@@ -196,7 +135,7 @@ class PRAutoBlogger_Run_Stage_State {
if ( '' === $run_id || ! self::is_available() ) {
return null;
}
$agent_role = self::resolve_role( $stage, $agent_role );
$agent_role = PRAutoBlogger_Run_Stage_Writes::resolve_role( $stage, $agent_role );
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -265,49 +204,68 @@ class PRAutoBlogger_Run_Stage_State {
/**
* Fail every still-open stage of one item. Done stages are untouched.
* Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged).
*
* @param string $run_id Run UUID.
* @param string $item_key Stage item key ('' = run-level stages).
*/
public static function fail_open_for_item( string $run_id, string $item_key ): void {
PRAutoBlogger_Run_Stage_Writes::fail_open_for_item( $run_id, $item_key );
}
/**
* All stage rows for one item of a run (plus run-level rows when
* $include_run_level), ordered by id. Read API for the rerun engine
* and the dossier's item-scoped assembly (v0.20.0).
*
* @param string $run_id Run UUID.
* @param string $item_key Item key ('idea:<hash>').
* @param bool $include_run_level Include item_key='' rows too.
* @return array<int, array<string, mixed>> Stage rows (ARRAY_A).
*/
public static function stages_for_item( string $run_id, string $item_key, bool $include_run_level = true ): array {
if ( '' === $run_id || ! self::is_available() ) {
return;
return array();
}
global $wpdb;
$table = self::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$sql = $include_run_level
? "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC"
: "SELECT * FROM {$table} WHERE run_id = %s AND item_key = %s ORDER BY id ASC";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- assembled from two fixed literals above.
$rows = $wpdb->get_results( $wpdb->prepare( $sql, $run_id, $item_key ), ARRAY_A );
return is_array( $rows ) ? $rows : array();
}
/**
* Which of the given runs carry at least one stale stage (batched --
* feeds the Review Queue warning chips without N+1; CPO product AC:
* a stale flag is present at publish). Self-healing: missing table
* or column returns [].
*
* @param array<string> $run_ids Run UUIDs.
* @return array<string> Subset of $run_ids having stale stages.
*/
public static function runs_with_stale_stages( array $run_ids ): array {
$run_ids = array_values( array_filter( array_map( 'strval', $run_ids ) ) );
if ( empty( $run_ids ) || ! self::is_available() ) {
return array();
}
global $wpdb;
$table = self::table_name();
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$rows = $wpdb->get_col(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
$now,
$now,
$run_id,
$item_key
"SELECT DISTINCT run_id FROM {$table} WHERE stale = 1 AND run_id IN ({$placeholders})",
$run_ids
)
);
return is_array( $rows ) ? array_map( 'strval', $rows ) : array();
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;
}
/**
* Resolve the effective agent role for a stage.
*
* '' → derive from Stage_Display_Map (unknown stages map to '').
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
*
* @param string $stage Stage name.
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
* @return string Effective agent role.
*/
private static function resolve_role( string $stage, string $agent_role ): string {
if ( '' !== $agent_role ) {
return $agent_role;
}
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Pipeline-path write operations over `wp_prautoblogger_run_stages`.
*
* Extracted from PRAutoBlogger_Run_Stage_State in v0.20.0 so that class
* stays under the 300-line cap (the M2 class-split pattern): start/done/
* fail/fail_open_for_item bodies moved here verbatim; Run_Stage_State
* keeps thin delegating proxies, so every existing call site is
* unchanged. One M3 behavior delta, isolated in done(): a freshly
* completed stage clears its `stale` flag (fresh output is never stale),
* with a self-healing legacy retry for half-migrated schemas that lack
* the v0.20.0 columns (same retry pattern as Cost_Tracker v0.18.0).
*
* Operator-action mutations (restart/mark_stale/demote) live in
* PRAutoBlogger_Run_Stage_Rerun_State, not here.
*
* Triggered by: PRAutoBlogger_Run_Stage_State proxies (pipeline call sites).
* Dependencies: Run_Stage_State (table probe), Stage_Display_Map (role).
*
* @see core/class-run-stage-state.php — Public API + read methods.
* @see core/class-run-stage-rerun-state.php — M3 operator-action writes.
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
*/
class PRAutoBlogger_Run_Stage_Writes {
/**
* Mark a stage as running. Sticky: done stages are never demoted.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name (see Stage_Display_Map).
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope ('' for run-level stages).
*/
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at)
VALUES (%s, %s, %s, %s, 'running', 1, %s, %s)
ON DUPLICATE KEY UPDATE
attempt = IF(status = 'done', attempt, attempt + 1),
status = IF(status = 'done', 'done', 'running'),
updated_at = VALUES(updated_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$now,
$now
)
);
}
/**
* Mark a stage done and persist its output for resume-without-recharge.
*
* v0.20.0: a fresh completion clears the `stale` flag (and never
* touches `human_modified` — that flag is sticky run audit). When the
* site's schema predates the v0.20.0 columns (cron fired before the
* admin_init migration pass), the write self-heals by retrying the
* pre-v0.20.0 statement instead of losing the checkpoint.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
* @param string|null $output Stage output snapshot (pruned after R days).
* @param float $cost_usd Cost attributed to this stage.
*/
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
$meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, stale, started_at, updated_at, finished_at)
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, 0, %s, %s, %s)
ON DUPLICATE KEY UPDATE
status = 'done',
cost_usd = VALUES(cost_usd),
meta_json = VALUES(meta_json),
stale = 0,
updated_at = VALUES(updated_at),
finished_at = VALUES(finished_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$cost_usd,
$meta,
$now,
$now,
$now
)
);
// Self-healing on a half-migrated schema (no `stale` column yet):
// retry with the pre-v0.20.0 statement rather than losing the
// resume checkpoint. Same pattern as Cost_Tracker (v0.18.0).
if ( false === $result ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$table}
(run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at)
VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
status = 'done',
cost_usd = VALUES(cost_usd),
meta_json = VALUES(meta_json),
updated_at = VALUES(updated_at),
finished_at = VALUES(finished_at)",
$run_id,
$stage,
$agent_role,
$item_key,
$cost_usd,
$meta,
$now,
$now,
$now
)
);
}
}
/**
* Mark a stage failed (does not demote a done stage).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope.
*/
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'",
$now,
$now,
$run_id,
$stage,
$agent_role,
$item_key
)
);
}
/**
* Fail every still-open stage of one item. Done stages are untouched.
*
* @param string $run_id Run UUID.
* @param string $item_key Stage item key ('' = run-level stages).
*/
public static function fail_open_for_item( string $run_id, string $item_key ): void {
if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$now = current_time( 'mysql' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
$now,
$now,
$run_id,
$item_key
)
);
}
/**
* Resolve the effective agent role for a stage.
*
* '' → derive from Stage_Display_Map (unknown stages map to '').
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
*
* Public in v0.20.0 (was private on Run_Stage_State) so the read API
* and the rerun classes share one resolution source.
*
* @param string $stage Stage name.
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
* @return string Effective agent role.
*/
public static function resolve_role( string $stage, string $agent_role ): string {
if ( '' !== $agent_role ) {
return $agent_role;
}
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
}
}

View File

@@ -252,6 +252,41 @@ class PRAutoBlogger_Run_State {
return is_array( $rows ) ? $rows : array();
}
/**
* Reopen a terminal run for operator-deliberate new spend (M3
* re-runs). done/failed/halted -> running; finished_at cleared; the
* cost ceiling is RE-SNAPSHOTTED from the current setting — the
* deliberate re-run action adopts the operator's current per-run
* policy (can lower as well as raise), exactly as a new run would.
* Accumulated reserved/settled spend is kept: the governor treats
* re-runs as new spend on the SAME run (CPO guardrail 4).
*
* The conditional UPDATE is the atomic gate: an actively executing
* run (pending/running) can never be reopened.
*
* @param string $run_id Run UUID.
* @return bool Whether the run transitioned to running.
*/
public static function reopen( string $run_id ): bool {
if ( '' === $run_id || ! self::is_available() ) {
return false;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET status = 'running', ceiling_usd = %f, updated_at = %s, finished_at = NULL
WHERE run_id = %s AND status IN ('done','failed','halted')",
self::ceiling_setting(),
current_time( 'mysql' ),
$run_id
)
);
return 1 === (int) $updated;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* INSERT-only store over `wp_prautoblogger_stage_inputs` (v0.20.0, M3).
*
* What: Immutable input-version rows for the edit + re-run feature.
* Two row kinds, discriminated by `source`:
* - 'human': an operator's edited request body for one stage. Each
* save creates the NEXT version for the (run, stage, role, item)
* scope — there is deliberately no UPDATE path in this class, so
* originals (and earlier forks) can never be overwritten (CPO
* guardrail 1; the executed original lives untouched in
* generation_log.request_json).
* - 'seed': the serialized Article_Idea persisted at worker start
* (stage='', version=1). Re-run-from-here reconstructs the EXACT
* idea from it — rebuilding the idea from post fields would risk
* an item_key hash mismatch (post_title is sanitized at insert)
* and a duplicate post.
* Self-healing: every method no-ops (null/false/empty) when the
* table is missing (half-migrated schema).
* Retention: human fork bodies are NULLed by the run-reaper after
* R days (same setting as all heavy payloads); seed rows are ~1KB
* structural state and are kept.
* Who triggers it: Dossier_Actions (save fork), Rerun_Executor (read fork
* / seed), Article_Worker (save seed), Run_Reaper (prune).
* Dependencies: WordPress $wpdb.
*
* @see admin/class-dossier-actions.php — Fork save endpoint.
* @see core/class-rerun-executor.php — Fork/seed consumption.
* @see core/class-run-reaper.php — Retention prune.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Stage_Input_Store {
/** Source discriminator: operator edit fork. */
public const SOURCE_HUMAN = 'human';
/** Source discriminator: idea seed persisted at worker start. */
public const SOURCE_SEED = 'seed';
/** @var bool|null Per-request "stage_inputs table exists" probe result. */
private static ?bool $table_ok = null;
/** @return string Fully-qualified stage_inputs table name. */
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'prautoblogger_stage_inputs';
}
/**
* Whether the stage_inputs table exists. Cached per request.
*
* @return bool
*/
public static function is_available(): bool {
if ( null !== self::$table_ok ) {
return self::$table_ok;
}
global $wpdb;
if ( null === $wpdb ) {
return false; // Not cached: $wpdb may appear later in boot.
}
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
self::$table_ok = ( $found === $table );
return self::$table_ok;
}
/**
* Save a human edit fork as the next version for its scope.
*
* INSERT-only by design. The version number is MAX(version)+1 for the
* scope; a concurrent-save collision on the UNIQUE key is retried
* once with a refreshed version.
*
* Side effects: one (rarely two) INSERTs into stage_inputs.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Stage row's agent role.
* @param string $item_key Stage row's item key.
* @param string $request_json Edited request body (full JSON, no headers).
* @param string $author WP user login of the editor.
* @return int|null New version number, or null on failure/missing table.
*/
public static function save_fork( string $run_id, string $stage, string $agent_role, string $item_key, string $request_json, string $author ): ?int {
if ( '' === $run_id || '' === $stage || ! self::is_available() ) {
return null;
}
for ( $attempt = 0; $attempt < 2; $attempt++ ) {
$version = self::next_version( $run_id, $stage, $agent_role, $item_key );
$inserted = self::insert_row( $run_id, $stage, $agent_role, $item_key, $version, self::SOURCE_HUMAN, $request_json, $author );
if ( $inserted ) {
return $version;
}
}
return null;
}
/**
* Latest human fork row for a scope, or null when none exist.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Agent role.
* @param string $item_key Item key.
* @return array<string, mixed>|null Row incl. version/request_json/author/created_at.
*/
public static function latest_fork( string $run_id, string $stage, string $agent_role, string $item_key ): ?array {
if ( '' === $run_id || ! self::is_available() ) {
return null;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table}
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND source = %s
ORDER BY version DESC LIMIT 1",
$run_id,
$stage,
$agent_role,
$item_key,
self::SOURCE_HUMAN
),
ARRAY_A
);
return is_array( $row ) ? $row : null;
}
/**
* Persist the idea seed for one run item (INSERT-once; replays no-op).
*
* @param string $run_id Run UUID.
* @param string $item_key Item key from Run_Stage_State::item_key_for_idea().
* @param string $idea_json Serialized Article_Idea::to_array() payload.
* @return void
*/
public static function save_seed( string $run_id, string $item_key, string $idea_json ): void {
if ( '' === $run_id || '' === $item_key || ! self::is_available() ) {
return;
}
if ( null !== self::get_seed( $run_id, $item_key ) ) {
return; // Seed already persisted (idempotent resume).
}
self::insert_row( $run_id, '', '', $item_key, 1, self::SOURCE_SEED, $idea_json, 'pipeline' );
}
/**
* The idea seed JSON for one run item, or null when absent.
*
* @param string $run_id Run UUID.
* @param string $item_key Item key.
* @return string|null
*/
public static function get_seed( string $run_id, string $item_key ): ?string {
if ( '' === $run_id || ! self::is_available() ) {
return null;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$json = $wpdb->get_var(
$wpdb->prepare(
"SELECT request_json FROM {$table}
WHERE run_id = %s AND stage = '' AND item_key = %s AND source = %s LIMIT 1",
$run_id,
$item_key,
self::SOURCE_SEED
)
);
return ( is_string( $json ) && '' !== $json ) ? $json : null;
}
/**
* Retention: NULL human fork bodies older than the cutoff. Seed rows
* are kept (structural, ~1KB). Version/author/flag metadata survives
* as the permanent audit trail.
*
* @param string $cutoff MySQL datetime; rows created before it are pruned.
* @return int Rows pruned.
*/
public static function prune_human_bodies( string $cutoff ): int {
if ( ! self::is_available() ) {
return 0;
}
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$table} SET request_json = NULL
WHERE source = %s AND request_json IS NOT NULL AND created_at < %s",
self::SOURCE_HUMAN,
$cutoff
)
);
return is_numeric( $result ) ? (int) $result : 0;
}
/** Reset per-request caches (tests). */
public static function flush_cache(): void {
self::$table_ok = null;
}
/**
* Next version number for a scope (1 when none exist).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Agent role.
* @param string $item_key Item key.
* @return int
*/
private static function next_version( string $run_id, string $stage, string $agent_role, string $item_key ): int {
global $wpdb;
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$max = $wpdb->get_var(
$wpdb->prepare(
"SELECT MAX(version) FROM {$table}
WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s",
$run_id,
$stage,
$agent_role,
$item_key
)
);
return ( null !== $max ? (int) $max : 0 ) + 1;
}
/**
* Raw row INSERT (the only write path — INSERT-only by construction).
*
* @param string $run_id Run UUID.
* @param string $stage Stage name ('' for seeds).
* @param string $agent_role Agent role.
* @param string $item_key Item key.
* @param int $version Version number.
* @param string $source 'human' or 'seed'.
* @param string $request_json Payload JSON.
* @param string $author Author label.
* @return bool Whether the row was inserted.
*/
private static function insert_row( string $run_id, string $stage, string $agent_role, string $item_key, int $version, string $source, string $request_json, string $author ): bool {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$inserted = $wpdb->insert(
self::table_name(),
array(
'run_id' => $run_id,
'stage' => $stage,
'agent_role' => $agent_role,
'item_key' => $item_key,
'version' => $version,
'source' => $source,
'request_json' => $request_json,
'author' => $author,
'created_at' => current_time( 'mysql' ),
)
);
return false !== $inserted;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Executes one stage replay from a saved input fork (v0.20.0, M3).
*
* What: Decodes an immutable stage_inputs fork body back into the
* (messages, model, options) triple and sends it through the
* normal provider seam — so the cost governor's reserve-before-
* call (on the SAME run ledger row), the retry/backoff loop, the
* empty-completion guard, and the B1 request recorder all apply
* to replays exactly as to pipeline calls (CPO guardrail 4). The
* fresh output lands in the stage's run_stages snapshot (the same
* place downstream rebuilds read from) and a generation_log row
* is written with the replayed request body.
*
* options_from_body() is the exact inverse of Request_Builder::
* build_body() + apply_reasoning_budget(): the v0.18.1 reasoning
* headroom is subtracted back out (the builder re-adds it), and
* reasoning is pinned explicitly — enabled:false when the original
* body had none — so a later change to the global reasoning
* setting can never alter the replay's shape.
* Who triggers it: PRAutoBlogger_Rerun_Executor::on_replay_job() only
* (after eligibility re-validation, lock, reopen, restart and
* stale-marking).
* Dependencies: OpenRouter_Provider, Cost_Tracker, Run_Stage_State,
* Audit_Writer, Stage_Display_Map.
*
* @see core/class-rerun-executor.php — Orchestration around this.
* @see providers/class-open-router-request-builder.php — The forward transform.
* @see ARCHITECTURE.md #24 — Edit + re-run design.
*/
class PRAutoBlogger_Stage_Replay {
/**
* Reconstruct provider options from a stored request body.
*
* Inverse of build_body()/apply_reasoning_budget(): copies
* temperature/response_format; recovers the caller's max_tokens by
* subtracting the reasoning cap the builder added as headroom; pins
* reasoning explicitly (the stored body is the contract — global
* setting drift must not leak in). Adds the caller-metadata keys
* (stage, prompt_key) used by the guard's logging — never sent
* upstream.
*
* @param array<string, mixed> $body Decoded request body.
* @param string $stage Stage being replayed.
* @return array<string, mixed> Options for send_chat_completion().
*/
public static function options_from_body( array $body, string $stage ): array {
$options = array();
if ( isset( $body['temperature'] ) ) {
$options['temperature'] = $body['temperature'];
}
if ( isset( $body['response_format'] ) ) {
$options['response_format'] = $body['response_format'];
}
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
if ( $reasoning_active ) {
$options['reasoning'] = $body['reasoning'];
if ( isset( $body['max_tokens'] ) ) {
$cap = isset( $body['reasoning']['max_tokens'] ) ? (int) $body['reasoning']['max_tokens'] : 0;
// The builder raised max_tokens by the cap (completion
// headroom); hand back the caller-level value so the
// builder's re-application is not compounded.
$options['max_tokens'] = max( 1, (int) $body['max_tokens'] - $cap );
}
} else {
// Pin reasoning OFF (same per-call override shape the
// empty-completion retry uses) so a globally-enabled
// reasoning setting cannot reshape the replay.
$options['reasoning'] = array( 'enabled' => false );
if ( isset( $body['max_tokens'] ) ) {
$options['max_tokens'] = (int) $body['max_tokens'];
}
}
$options['stage'] = $stage;
$options['prompt_key'] = PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage );
return $options;
}
/**
* Decode + validate a fork body. Returns null when the JSON is not a
* replayable chat body (model string + non-empty messages list of
* role/content pairs).
*
* @param string $request_json Stored fork body.
* @return array<string, mixed>|null Decoded body, or null when invalid.
*/
public static function decode_body( string $request_json ): ?array {
$body = json_decode( $request_json, true );
if ( ! is_array( $body ) || empty( $body['model'] ) || ! is_string( $body['model'] ) ) {
return null;
}
if ( empty( $body['messages'] ) || ! is_array( $body['messages'] ) ) {
return null;
}
foreach ( $body['messages'] as $message ) {
if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] )
|| ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) {
return null;
}
}
return $body;
}
/**
* Execute the replay call and persist its results.
*
* The caller (Rerun_Executor) has already: re-validated eligibility,
* taken the generation lock, reopened the run, demoted the stage row
* to running and stale-marked downstream. This method performs the
* governed LLM call and the success-path writes.
*
* Side effects: one OpenRouter call (governor-reserved), run_stages
* done-write, one generation_log row (with the replayed request via
* the B1 recorder), run_decisions human_modified flags.
*
* @param string $run_id Run UUID.
* @param string $stage Stage name.
* @param string $agent_role Stage row agent role.
* @param string $item_key Stage row item key.
* @param array<string, mixed> $body Decoded fork body (from decode_body()).
* @return array{content: string, cost: float, model: string} Replay result.
*
* @throws \RuntimeException On provider failure or empty output.
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the governor blocks the call.
*/
public static function run( string $run_id, string $stage, string $agent_role, string $item_key, array $body ): array {
$tracker = new PRAutoBlogger_Cost_Tracker();
// Joins this process to the run: Run_Context (governor guard),
// ledger row, prompt pins — replay spend reserves against the
// SAME per-run ceiling as the original calls (guardrail 4).
$tracker->set_run_id( $run_id );
$llm = new PRAutoBlogger_OpenRouter_Provider();
$options = self::options_from_body( $body, $stage );
$response = $llm->send_chat_completion( $body['messages'], (string) $body['model'], $options );
// Same belt as the writer path: an empty replay output must fail
// the stage, never overwrite a good snapshot with nothing.
if ( '' === trim( (string) $response['content'] ) ) {
throw new \RuntimeException(
sprintf(
/* translators: 1: stage name, 2: model identifier. */
__( 'Replay of stage "%1$s" produced empty content (model %2$s) — failing the replay instead of storing an empty output.', 'prautoblogger' ),
$stage,
(string) $response['model']
)
);
}
$cost = $llm->estimate_cost(
(string) $response['model'],
(int) $response['prompt_tokens'],
(int) $response['completion_tokens']
);
PRAutoBlogger_Run_Stage_State::done( $run_id, $stage, $agent_role, $item_key, (string) $response['content'], $cost );
$tracker->log_api_call(
null,
$stage,
$llm->get_provider_name(),
(string) $response['model'],
(int) $response['prompt_tokens'],
(int) $response['completion_tokens'],
'success',
'',
'' !== $agent_role ? $agent_role : null,
null
);
// Guardrail 2: decision rows for the replayed stage carry the
// human_modified flag (no-op when the stage has none).
PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, array( $stage ) );
return array(
'content' => (string) $response['content'],
'cost' => $cost,
'model' => (string) $response['model'],
);
}
}

View File

@@ -78,6 +78,13 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
// reasoning token cap + completion headroom — lives in the builder.
$body = $builder->build_body( $messages, $model, $options );
// v0.20.0 (B1): stash the outgoing request body so the next
// log_api_call() persists it to generation_log.request_json.
// Only the BODY is recorded -- Authorization lives exclusively in
// build_headers()' return and can never reach the recorder.
// Recorded BEFORE dispatch so error/timeout rows carry it too.
PRAutoBlogger_Request_Recorder::record( $body );
// Whether the outgoing request has reasoning enabled — drives the
// empty-completion retry decision after parse.
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );