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:
peptiderepo
2026-06-12 13:05:55 +00:00
parent 1b31ef6396
commit aaf1b4274e
11 changed files with 1137 additions and 115 deletions

View 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 );
}
}

View 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 );
}
}