From aaf1b4274e1eb2d8e352b63d6d8a1a3016a03c33 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 12 Jun 2026 13:05:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(M3):=20edit+rerun=20schema=20substrate=20?= =?UTF-8?q?=E2=80=94=20stage=5Finputs=20store,=20stale/human=5Fmodified=20?= =?UTF-8?q?columns,=20db=201.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pipeline_Schema_Installer: run_stages gains human_modified + stale (stale is a COLUMN not a status: rows stay 'done' so resume code can never silently re-run them — CPO guardrail 3 by construction); run_decisions gains human_modified; new INSERT-only stage_inputs table (edit forks as immutable versions + idea seeds for re-run-from-here). All dbDelta-idempotent; PRAUTOBLOGGER_DB_VERSION 1.1.0 -> 1.3.0 (the constant never carried the documented 1.2.0) so the existing admin_init/cron self-healing migration path fires everywhere. - Run_Stage_State split (M2 pattern, was 313 lines): pipeline write bodies moved verbatim to Run_Stage_Writes (public API unchanged via thin proxies); done() now clears stale on fresh output with a half-migrated-schema retry (Cost_Tracker v0.18.0 pattern); new item-scoped read stages_for_item(). Operator-action mutations (restart/mark_stale/demote_to_pending) isolated in Run_Stage_Rerun_State — the only code allowed to demote a done row. - Audit_Writer::flag_decisions_human_modified() (guardrail 2 on run_decisions). Run_Reaper retention prunes human fork bodies (seeds kept). Uninstall covers the new table + rerun cron hooks. Agent-Session: engineer-prab-m3-20260612 --- includes/class-pipeline-schema-installer.php | 40 ++- includes/core/class-audit-writer.php | 31 ++ includes/core/class-run-reaper.php | 8 +- includes/core/class-run-stage-rerun-state.php | 148 ++++++++++ includes/core/class-run-stage-state.php | 150 +++------- includes/core/class-run-stage-writes.php | 222 +++++++++++++++ includes/core/class-stage-input-store.php | 269 ++++++++++++++++++ prautoblogger.php | 2 +- tests/unit/Core/RunStageRerunStateTest.php | 185 ++++++++++++ tests/unit/Core/StageInputStoreTest.php | 193 +++++++++++++ uninstall.php | 4 + 11 files changed, 1137 insertions(+), 115 deletions(-) create mode 100644 includes/core/class-run-stage-rerun-state.php create mode 100644 includes/core/class-run-stage-writes.php create mode 100644 includes/core/class-stage-input-store.php create mode 100644 tests/unit/Core/RunStageRerunStateTest.php create mode 100644 tests/unit/Core/StageInputStoreTest.php diff --git a/includes/class-pipeline-schema-installer.php b/includes/class-pipeline-schema-installer.php index aa76082..3ceee7c 100644 --- a/includes/class-pipeline-schema-installer.php +++ b/includes/class-pipeline-schema-installer.php @@ -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 ); } } diff --git a/includes/core/class-audit-writer.php b/includes/core/class-audit-writer.php index 55537b5..9ad7d46 100644 --- a/includes/core/class-audit-writer.php +++ b/includes/core/class-audit-writer.php @@ -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 $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; diff --git a/includes/core/class-run-reaper.php b/includes/core/class-run-reaper.php index 6f1c86e..da85734 100644 --- a/includes/core/class-run-reaper.php +++ b/includes/core/class-run-reaper.php @@ -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; } } diff --git a/includes/core/class-run-stage-rerun-state.php b/includes/core/class-run-stage-rerun-state.php new file mode 100644 index 0000000..30c5b14 --- /dev/null +++ b/includes/core/class-run-stage-rerun-state.php @@ -0,0 +1,148 @@ +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 $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 $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; + } +} diff --git a/includes/core/class-run-stage-state.php b/includes/core/class-run-stage-state.php index 548ca71..e29f76d 100644 --- a/includes/core/class-run-stage-state.php +++ b/includes/core/class-run-stage-state.php @@ -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,42 @@ 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:'). + * @param bool $include_run_level Include item_key='' rows too. + * @return array> 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( - $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 - ) - ); + $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(); } - /** Reset per-request caches (tests). */ + /** Reset per-request caches (tests). */ /** 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 ); - } } diff --git a/includes/core/class-run-stage-writes.php b/includes/core/class-run-stage-writes.php new file mode 100644 index 0000000..ccceda4 --- /dev/null +++ b/includes/core/class-run-stage-writes.php @@ -0,0 +1,222 @@ +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 ); + } +} diff --git a/includes/core/class-stage-input-store.php b/includes/core/class-stage-input-store.php new file mode 100644 index 0000000..d9b4f40 --- /dev/null +++ b/includes/core/class-stage-input-store.php @@ -0,0 +1,269 @@ +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|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; + } +} diff --git a/prautoblogger.php b/prautoblogger.php index 400c0cc..e6546bc 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -36,7 +36,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ define( 'PRAUTOBLOGGER_VERSION', '0.19.4' ); -define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); +define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/tests/unit/Core/RunStageRerunStateTest.php b/tests/unit/Core/RunStageRerunStateTest.php new file mode 100644 index 0000000..4176793 --- /dev/null +++ b/tests/unit/Core/RunStageRerunStateTest.php @@ -0,0 +1,185 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' ); + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_Stage_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Capture queries, all succeeding. */ + private function capture_queries(): void { + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + } + + /** + * done() writes stale = 0 on both INSERT and duplicate-key paths and + * never references human_modified (sticky audit — set only by + * Rerun_State::restart()). + */ + public function test_done_clears_stale_and_never_touches_human_modified(): void { + $this->capture_queries(); + + \PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', 'idea:abc', 'output text', 0.01 ); + + $this->assertCount( 1, $this->queries ); + $sql = $this->queries[0]; + $this->assertStringContainsString( 'stale', $sql ); + $this->assertStringContainsString( 'stale = 0', $sql ); + $this->assertStringNotContainsString( 'human_modified', $sql ); + } + + /** + * Half-migrated schema (no stale column yet): the failed write is + * retried with the pre-v0.20.0 statement — the resume checkpoint is + * never lost (Cost_Tracker v0.18.0 retry pattern). + */ + public function test_done_self_heals_on_missing_stale_column(): void { + $calls = 0; + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) use ( &$calls ) { + $this->queries[] = (string) $sql; + ++$calls; + return 1 === $calls ? false : 1; // First write fails (unknown column). + } + ); + + \PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', 'idea:abc', 'output', 0.01 ); + + $this->assertCount( 2, $this->queries ); + $this->assertStringContainsString( 'stale', $this->queries[0] ); + $this->assertStringNotContainsString( 'stale', $this->queries[1] ); + $this->assertStringContainsString( "status = 'done'", $this->queries[1] ); + } + + /** + * restart() demotes terminal/pending rows only — a 'running' row can + * never be restarted (the WHERE excludes it), the attempt counter is + * bumped, stale clears, and human_modified can only be raised + * (IF(...,1,human_modified)), never reset. + */ + public function test_restart_rules(): void { + $this->capture_queries(); + + $ok = \PRAutoBlogger_Run_Stage_Rerun_State::restart( 'run-1', 'draft', 'writer', 'idea:abc', true ); + + $this->assertTrue( $ok ); + $sql = $this->queries[0]; + $this->assertStringContainsString( "status = 'running'", $sql ); + $this->assertStringContainsString( 'attempt = attempt + 1', $sql ); + $this->assertStringContainsString( 'stale = 0', $sql ); + $this->assertStringContainsString( 'IF(%d = 1, 1, human_modified)', $sql ); + $this->assertStringContainsString( "IN ('done','failed','halted','pending')", $sql ); + $this->assertStringNotContainsString( "'running')", str_replace( "status = 'running'", '', $sql ) ); + } + + /** + * restart() reports false when no row matched (e.g. concurrently + * running stage) so the executor can abort cleanly. + */ + public function test_restart_returns_false_when_no_row_demoted(): void { + $this->wpdb->method( 'query' )->willReturn( 0 ); + + $this->assertFalse( + \PRAutoBlogger_Run_Stage_Rerun_State::restart( 'run-1', 'draft', 'writer', 'idea:abc', false ) + ); + } + + /** + * mark_stale() flags only 'done' rows of the given stages — pending/ + * failed rows are not "stale", they are simply not run. Nothing is + * demoted (no status write): guardrail 3's no-silent-auto-rerun holds + * by construction. + */ + public function test_mark_stale_targets_done_rows_only_and_never_demotes(): void { + $this->capture_queries(); + + $count = \PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( 'run-1', 'idea:abc', array( 'polish', 'review', 'publish' ) ); + + $this->assertSame( 1, $count ); + $sql = $this->queries[0]; + $this->assertStringContainsString( 'SET stale = 1', $sql ); + $this->assertStringContainsString( "status = 'done'", $sql ); + $this->assertStringContainsString( 'IN (%s,%s,%s)', $sql ); + $this->assertStringNotContainsString( "SET status", $sql ); + } + + /** + * demote_to_pending() sets pending+stale, keeps meta_json/attempt/ + * human_modified untouched (audit survives), and never touches a + * 'running' row. + */ + public function test_demote_to_pending_preserves_audit_columns(): void { + $this->capture_queries(); + + \PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( 'run-1', 'idea:abc', array( 'review', 'publish' ) ); + + $sql = $this->queries[0]; + $this->assertStringContainsString( "SET status = 'pending', stale = 1", $sql ); + $this->assertStringContainsString( "status != 'running'", $sql ); + $this->assertStringNotContainsString( 'meta_json', $sql ); + $this->assertStringNotContainsString( 'attempt', $sql ); + $this->assertStringNotContainsString( 'human_modified', $sql ); + } + + /** + * Empty stage lists and missing tables degrade to 0 / no queries. + */ + public function test_noop_paths(): void { + $this->capture_queries(); + + $this->assertSame( 0, \PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( 'run-1', 'i', array() ) ); + $this->assertSame( 0, \PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( '', 'i', array( 'draft' ) ) ); + $this->assertCount( 0, $this->queries ); + } +} diff --git a/tests/unit/Core/StageInputStoreTest.php b/tests/unit/Core/StageInputStoreTest.php new file mode 100644 index 0000000..fdd7086 --- /dev/null +++ b/tests/unit/Core/StageInputStoreTest.php @@ -0,0 +1,193 @@ +insert(). + * + * @var array> + */ + private array $inserted = array(); + + /** + * SQL captured from $wpdb->query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->inserted = array(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $this->wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) { + $this->inserted[] = (array) $row; + return 1; + } + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Make the availability probe succeed. */ + private function table_exists(): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + return null; // MAX(version) on an empty scope. + } + ); + } + + /** + * First fork for a scope gets version 1 via INSERT — and the write + * path is INSERT-only: no UPDATE statement may ever be issued. + */ + public function test_save_fork_inserts_version_one_and_never_updates(): void { + $this->table_exists(); + + $version = \PRAutoBlogger_Stage_Input_Store::save_fork( + 'run-1', + 'draft', + 'writer', + 'idea:abc', + '{"model":"m","messages":[]}', + 'rhys' + ); + + $this->assertSame( 1, $version ); + $this->assertCount( 1, $this->inserted ); + $row = $this->inserted[0]; + $this->assertSame( 'human', $row['source'] ); + $this->assertSame( 1, $row['version'] ); + $this->assertSame( 'rhys', $row['author'] ); + // Immutability: the store issued no UPDATE of any kind. + foreach ( $this->queries as $sql ) { + $this->assertStringNotContainsString( 'UPDATE', $sql ); + } + } + + /** + * The next fork for the same scope gets MAX(version)+1 — the original + * version row is never overwritten (CPO guardrail 1). + */ + public function test_save_fork_increments_version_from_existing_max(): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + return '3'; // Existing max version. + } + ); + + $version = \PRAutoBlogger_Stage_Input_Store::save_fork( 'run-1', 'draft', 'writer', 'idea:abc', '{}', 'rhys' ); + + $this->assertSame( 4, $version ); + $this->assertSame( 4, $this->inserted[0]['version'] ); + } + + /** + * Seed persists once: a second save for the same item is a no-op + * (idempotent worker resume). + */ + public function test_save_seed_is_insert_once(): void { + $seeded = false; + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) use ( &$seeded ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + if ( false !== strpos( (string) $sql, 'SELECT request_json' ) ) { + return $seeded ? '{"topic":"t"}' : null; + } + return null; + } + ); + + \PRAutoBlogger_Stage_Input_Store::save_seed( 'run-1', 'idea:abc', '{"topic":"t"}' ); + $seeded = true; + \PRAutoBlogger_Stage_Input_Store::save_seed( 'run-1', 'idea:abc', '{"topic":"other"}' ); + + $this->assertCount( 1, $this->inserted ); + $this->assertSame( 'seed', $this->inserted[0]['source'] ); + $this->assertSame( '', $this->inserted[0]['stage'] ); + } + + /** + * Retention prune targets ONLY human fork bodies — the WHERE clause + * pins source='human', so seed rows survive (re-run-from-here keeps + * working for the life of the run). + */ + public function test_prune_targets_human_bodies_only(): void { + $this->table_exists(); + + \PRAutoBlogger_Stage_Input_Store::prune_human_bodies( '2026-05-29 00:00:00' ); + + $this->assertCount( 1, $this->queries ); + $sql = $this->queries[0]; + $this->assertStringContainsString( 'SET request_json = NULL', $sql ); + $this->assertStringContainsString( "source = %s", $sql ); + $this->assertStringContainsString( 'human', $sql ); + $this->assertStringNotContainsString( "seed", $sql ); + } + + /** + * Missing table (half-migrated schema): every method degrades to a + * silent no-op / null — never a query, never a notice. + */ + public function test_missing_table_degrades_to_noop(): void { + $this->wpdb->method( 'get_var' )->willReturn( null ); + + $this->assertNull( \PRAutoBlogger_Stage_Input_Store::save_fork( 'r', 'draft', 'w', 'i', '{}', 'a' ) ); + \PRAutoBlogger_Stage_Input_Store::save_seed( 'r', 'i', '{}' ); + $this->assertNull( \PRAutoBlogger_Stage_Input_Store::get_seed( 'r', 'i' ) ); + $this->assertNull( \PRAutoBlogger_Stage_Input_Store::latest_fork( 'r', 'draft', 'w', 'i' ) ); + $this->assertSame( 0, \PRAutoBlogger_Stage_Input_Store::prune_human_bodies( '2026-05-29 00:00:00' ) ); + $this->assertCount( 0, $this->inserted ); + $this->assertCount( 0, $this->queries ); + } +} diff --git a/uninstall.php b/uninstall.php index 634d8e6..784ae84 100644 --- a/uninstall.php +++ b/uninstall.php @@ -37,6 +37,8 @@ $tables = array( $prefix . 'run_decisions', $prefix . 'runs', $prefix . 'run_stages', + // Edit + re-run input versions (v0.20.0 / db 1.3.0). + $prefix . 'stage_inputs', ); foreach ( $tables as $table ) { @@ -93,6 +95,8 @@ $hooks = array( 'prautoblogger_generate_queued_article', 'prautoblogger_generate_from_idea', 'prautoblogger_opik_dispatch', + 'prautoblogger_rerun_stage_replay', + 'prautoblogger_rerun_from_stage', ); foreach ( $hooks as $hook ) {