feat(M3): edit+rerun schema substrate — stage_inputs store, stale/human_modified columns, db 1.3.0
- 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
This commit is contained in:
185
tests/unit/Core/RunStageRerunStateTest.php
Normal file
185
tests/unit/Core/RunStageRerunStateTest.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Run_Stage_Writes (M3 done() delta) and
|
||||
* PRAutoBlogger_Run_Stage_Rerun_State (operator-action mutations).
|
||||
*
|
||||
* Locks: fresh done() clears stale but never touches human_modified;
|
||||
* the half-migrated-schema self-heal retry; restart() demotion rules
|
||||
* (never from 'running', human_modified is sticky); mark_stale() flags
|
||||
* only 'done' rows; demote_to_pending() preserves audit columns.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class RunStageRerunStateTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* SQL captured from $wpdb->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 );
|
||||
}
|
||||
}
|
||||
193
tests/unit/Core/StageInputStoreTest.php
Normal file
193
tests/unit/Core/StageInputStoreTest.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Stage_Input_Store (v0.20.0, M3).
|
||||
*
|
||||
* Locks the INSERT-only immutability contract (fork saves create new
|
||||
* versions, nothing is ever updated), seed persist-once semantics, the
|
||||
* retention prune scope (human bodies only, seeds kept), and the
|
||||
* self-healing missing-table degradation.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class StageInputStoreTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Rows captured from $wpdb->insert().
|
||||
*
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user