Compare commits
4 Commits
claude/pha
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579c66ecab | ||
|
|
91dea0501a | ||
|
|
72bfd232a4 | ||
|
|
682c98e981 |
145
ARCHITECTURE.md
145
ARCHITECTURE.md
@@ -121,19 +121,17 @@ prautoblogger/
|
|||||||
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
||||||
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
|
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
|
||||||
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
|
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
|
||||||
│ │ ├── dossier-edit.css # M3 edit/re-run layer: chips, edit panel, image grid, spend warning (v0.20.0)
|
|
||||||
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
|
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
|
||||||
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
|
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
|
||||||
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
|
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
|
||||||
│ ├── dossier-edit.js # M3 edit/re-run actions + stage-status polling (queued -> pickup -> result) (v0.20.0)
|
|
||||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||||
│
|
│
|
||||||
├── includes/
|
├── includes/
|
||||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (split v0.19.2)
|
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; split v0.19.2)
|
||||||
│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1)
|
│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1)
|
||||||
│ ├── class-pipeline-schema-installer.php # Pipeline substrate tables (prompts, run_sources, run_decisions, runs, run_stages, stage_inputs)
|
│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
|
||||||
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
|
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
|
||||||
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
|
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
|
||||||
│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run
|
│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run
|
||||||
@@ -147,12 +145,9 @@ prautoblogger/
|
|||||||
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
||||||
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
||||||
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
|
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
|
||||||
│ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
|
│ │ ├── class-dossier-page.php # Article dossier admin page: hidden-submenu, priority 12, enqueue, nonce (v0.19.2)
|
||||||
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
|
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
|
||||||
│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2)
|
│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2)
|
||||||
│ │ ├── class-dossier-actions.php # M3 AJAX: save fork / queue replay / queue rebuild / stage-status poll (v0.20.0)
|
|
||||||
│ │ ├── class-dossier-rerun-panel-data.php # M3 per-stage affordance + spend view model (v0.20.0)
|
|
||||||
│ │ ├── class-dossier-image-data.php # F3: post's pipeline attachments via _prautoblogger_image_role (v0.20.0)
|
|
||||||
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||||
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||||
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||||
@@ -199,15 +194,7 @@ prautoblogger/
|
|||||||
│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status)
|
│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status)
|
||||||
│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE)
|
│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE)
|
||||||
│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted)
|
│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted)
|
||||||
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine: read API + thin write proxies (split v0.20.0)
|
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine (idempotent resume, output snapshots)
|
||||||
│ │ ├── class-run-stage-writes.php # Pipeline-path writes (start/done/fail; done clears stale) — extracted v0.20.0
|
|
||||||
│ │ ├── class-run-stage-rerun-state.php # M3 operator-action mutations: restart/mark_stale/demote (v0.20.0)
|
|
||||||
│ │ ├── class-stage-input-store.php # INSERT-only stage_inputs store: edit forks + idea seeds (v0.20.0)
|
|
||||||
│ │ ├── class-rerun-eligibility.php # M3 policy gates: frozen posts, editable stages, chain order (v0.20.0)
|
|
||||||
│ │ ├── class-stage-replay.php # M3 governed single-stage replay from a fork body (v0.20.0)
|
|
||||||
│ │ ├── class-rerun-executor.php # M3 cron handlers: replay + rebuild jobs (v0.20.0)
|
|
||||||
│ │ ├── class-rerun-job-support.php # M3 queue/lock/budget/status-transient plumbing (v0.20.0)
|
|
||||||
│ │ ├── class-request-recorder.php # B1: process-scoped outbound request-body stash -> generation_log.request_json (v0.20.0)
|
|
||||||
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
|
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
|
||||||
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
|
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
|
||||||
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
|
||||||
@@ -265,10 +252,6 @@ prautoblogger/
|
|||||||
│ └── admin/
|
│ └── admin/
|
||||||
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
|
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
|
||||||
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
|
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
|
||||||
│ ├── dossier-stage-section.php # Stage section partial: output, raw trace, M3 chips + rerun footer
|
|
||||||
│ ├── dossier-edit-panel.php # M3 per-stage edit panel: message textareas, fork info, spend strip (v0.20.0)
|
|
||||||
│ ├── dossier-log-stage-section.php # F3 log-only stage sections incl. image attachment grid (v0.20.0)
|
|
||||||
│ ├── dossier-sidebar-cards.php # M3 sidebar: run spend (guardrail 4) + models/pv consolidation (F2) (v0.20.0)
|
|
||||||
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
|
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
|
||||||
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
|
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
|
||||||
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
||||||
@@ -549,7 +532,6 @@ dropped on uninstall.
|
|||||||
| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' |
|
| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' |
|
||||||
| rationale | TEXT | Decision rationale (nullable) |
|
| rationale | TEXT | Decision rationale (nullable) |
|
||||||
| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it |
|
| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it |
|
||||||
| human_modified | TINYINT(1) | v0.20.0 — decision derives from a human-edited input (set on replay of the stage, or on decisions recorded while rebuilding a human-modified item) |
|
|
||||||
| created_at | DATETIME | Row creation time |
|
| created_at | DATETIME | Row creation time |
|
||||||
|
|
||||||
- INDEX: `run_id`
|
- INDEX: `run_id`
|
||||||
@@ -586,38 +568,12 @@ dropped on uninstall.
|
|||||||
| attempt | SMALLINT UNSIGNED | Re-entry counter |
|
| attempt | SMALLINT UNSIGNED | Re-entry counter |
|
||||||
| cost_usd | DECIMAL(10,6) | Cost attributed to the stage |
|
| cost_usd | DECIMAL(10,6) | Cost attributed to the stage |
|
||||||
| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) |
|
| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) |
|
||||||
| human_modified | TINYINT(1) | v0.20.0 — set (sticky, never cleared) when an edited input fork enters execution for this row (CPO guardrail 2) |
|
|
||||||
| stale | TINYINT(1) | v0.20.0 — upstream changed after this row completed; row STAYS 'done' so resume never silently re-runs it; cleared only by a fresh done() |
|
|
||||||
| started_at | DATETIME | First entry |
|
| started_at | DATETIME | First entry |
|
||||||
| updated_at | DATETIME | Last transition |
|
| updated_at | DATETIME | Last transition |
|
||||||
| finished_at | DATETIME | Set on done/failed (nullable) |
|
| finished_at | DATETIME | Set on done/failed (nullable) |
|
||||||
|
|
||||||
- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at`
|
- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at`
|
||||||
|
|
||||||
##### `prautoblogger_stage_inputs` — immutable input-version store (v0.20.0, db 1.3.0)
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|--------------|-----------------|--------------------------------------------------|
|
|
||||||
| id | BIGINT UNSIGNED | Auto-increment PK |
|
|
||||||
| run_id | VARCHAR(36) | Pipeline run UUID |
|
|
||||||
| stage | VARCHAR(50) | Stage name ('' for idea seeds) |
|
|
||||||
| agent_role | VARCHAR(50) | Stage row agent role |
|
|
||||||
| item_key | VARCHAR(64) | Article scope |
|
|
||||||
| version | INT UNSIGNED | Monotonic per scope; **rows are INSERT-only** — edits create the next version, originals are never overwritten |
|
|
||||||
| source | VARCHAR(20) | 'human' (edit fork) or 'seed' (Article_Idea payload persisted at worker start) |
|
|
||||||
| request_json | LONGTEXT | Fork body / idea JSON. Human fork bodies pruned by the retention cron after R days; seed rows (~1KB) kept |
|
|
||||||
| author | VARCHAR(100) | WP user login, or 'pipeline' for seeds |
|
|
||||||
| created_at | DATETIME | Row creation time |
|
|
||||||
|
|
||||||
- UNIQUE: `run_id, stage, agent_role, item_key, version` · INDEX: `run_id`
|
|
||||||
- The ORIGINAL executed input of a stage is `generation_log.request_json` (B1) — it is
|
|
||||||
never copied or modified here; forks are the edit history (CPO guardrail 1).
|
|
||||||
|
|
||||||
**db version note:** `PRAUTOBLOGGER_DB_VERSION` moved 1.1.0 → 1.3.0 in v0.20.0. The
|
|
||||||
constant never actually carried the "db 1.2.0" this document used as shorthand for the
|
|
||||||
v0.18.0 substrate; 1.3.0 leapfrogs the phantom value so the version-compare self-healing
|
|
||||||
migration fires exactly once everywhere.
|
|
||||||
|
|
||||||
### WordPress Options (`wp_options`)
|
### WordPress Options (`wp_options`)
|
||||||
|
|
||||||
All prefixed with `prautoblogger_`:
|
All prefixed with `prautoblogger_`:
|
||||||
@@ -915,8 +871,6 @@ Tests use Brain\Monkey for WordPress function mocking (no database required). Th
|
|||||||
|
|
||||||
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
|
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
|
||||||
|
|
||||||
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
|
|
||||||
|
|
||||||
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
|
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
|
||||||
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
|
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
|
||||||
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
||||||
@@ -950,25 +904,14 @@ a single post: generation run, stage I/O, cost receipts, raw-trace toggles, edit
|
|||||||
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
|
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
|
||||||
entry appears in the WP sidebar.
|
entry appears in the WP sidebar.
|
||||||
|
|
||||||
**Page registration** (`class-dossier-page.php`): uses the canonical hidden-admin-page
|
**Page registration** (`class-dossier-page.php`): registered as a hidden submenu under
|
||||||
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
|
`prautoblogger-settings` at `admin_menu` priority **12**, one step after the board (priority
|
||||||
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
|
11), which is itself after the parent menu (priority 10). The page is immediately removed
|
||||||
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
|
from `$submenu` so it does not appear in the WP admin sidebar, but remains reachable by
|
||||||
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
|
direct URL. URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>`. Secured by
|
||||||
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
|
`manage_options` capability + nonce (`prautoblogger_dossier`). Static `url_for_post(int
|
||||||
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
|
$post_id): string` provides the canonical deep-link. Regression test:
|
||||||
`url_for_post(int $post_id): string` provides the canonical deep-link.
|
`tests/unit/Admin/DossierMenuRegistrationTest.php`.
|
||||||
|
|
||||||
v0.19.2 used `prautoblogger-settings` as parent with post-registration `unset($submenu[...])` to
|
|
||||||
hide the page. This caused `get_admin_page_parent()` to fail at request time -> orphan
|
|
||||||
`admin_page_*` hookname -> no handler registered -> `wp_die(403)`. Fixed in v0.19.3 by switching
|
|
||||||
to `options.php` parent and removing all `$submenu` mutation. See CONVENTIONS.md §Hidden Admin
|
|
||||||
Pages for the full rule and ARCHITECTURE.md §22b for both incident records.
|
|
||||||
|
|
||||||
Regression test: `tests/unit/Admin/DossierMenuRegistrationTest.php` -- the
|
|
||||||
`test_request_time_hookname_matches_registration_hookname` test replicates the request-time
|
|
||||||
resolution algorithm (simulate `get_admin_page_parent()`) so it FAILS on hide-by-unset and
|
|
||||||
PASSES on options.php-parent.
|
|
||||||
|
|
||||||
**5-query data assembly** (`class-dossier-data-assembler.php`):
|
**5-query data assembly** (`class-dossier-data-assembler.php`):
|
||||||
|
|
||||||
@@ -1008,70 +951,6 @@ the file `class-db-migrations.php`. Explicit loading is the canonical pattern fo
|
|||||||
whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
|
whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
|
||||||
uses the same pattern (also explicitly required in `prautoblogger.php`).
|
uses the same pattern (also explicitly required in `prautoblogger.php`).
|
||||||
|
|
||||||
### #24: Edit + single-step re-run under CPO guardrails (v0.20.0, db 1.3.0, June 2026)
|
|
||||||
|
|
||||||
Phase 2 admin M3 (design contract: cpo seq 8 Proposal C + the five guardrails of the
|
|
||||||
edit+rerun delta ruling; decision `2026-06-12-admin-redesign-direction-c.md`).
|
|
||||||
|
|
||||||
**B1 — request_json persistence.** The OpenRouter provider stashes every outgoing chat
|
|
||||||
request BODY in the process-scoped `PRAutoBlogger_Request_Recorder` (the Run_Context
|
|
||||||
pattern) right after `build_body()`; `Cost_Tracker::log_api_call()` consumes it into
|
|
||||||
`generation_log.request_json`. Consume-once + overwrite-on-record means a stale body can
|
|
||||||
never attach to an unrelated row; recording happens pre-dispatch so error rows carry the
|
|
||||||
request too. Authorization can never be recorded: headers are built separately
|
|
||||||
(`build_headers()`) and `build_body()` copies only whitelisted option keys. Retention
|
|
||||||
rides the existing `prautoblogger_request_json_retention_days` prune unchanged.
|
|
||||||
|
|
||||||
**Two re-run primitives, both chained-cron (never synchronous):** the dossier AJAX layer
|
|
||||||
(`Dossier_Actions`) only validates + queues; the cron handlers (`Rerun_Executor`)
|
|
||||||
re-validate eligibility UNDER the generation lock before mutating anything.
|
|
||||||
|
|
||||||
1. **Replay** — one governed chat call from the latest edited input fork
|
|
||||||
(`stage_inputs`, INSERT-only versions). `Stage_Replay::options_from_body()` is the
|
|
||||||
exact inverse of `build_body()`+`apply_reasoning_budget()` (reasoning headroom
|
|
||||||
subtracted back out; reasoning pinned explicitly so global-setting drift cannot
|
|
||||||
reshape a replay). The call goes through `send_chat_completion`, so the cost
|
|
||||||
governor reserves against the SAME run ledger row (guardrail 4), with the normal
|
|
||||||
retry/empty-completion machinery. Output lands in the stage's run_stages snapshot —
|
|
||||||
the same place downstream rebuilds read from.
|
|
||||||
2. **Rebuild ("re-run from here")** — demotes the target + downstream chain stages to
|
|
||||||
`pending` and re-enters `Article_Worker` with the idea reconstructed from the stored
|
|
||||||
seed (`stage_inputs`, source='seed', persisted at worker start because re-deriving
|
|
||||||
the idea from post fields could break the item_key hash and duplicate a post).
|
|
||||||
Upstream done stages are reused (never re-charged); demoted stages rebuild their
|
|
||||||
prompts from CURRENT upstream snapshots — this is how an edit propagates downstream.
|
|
||||||
|
|
||||||
**Stale is a column, not a status.** A stale stage stays `done`, so
|
|
||||||
resume-without-recharge logic can never silently re-run it — guardrail 3 (no silent
|
|
||||||
auto-rerun; each downstream re-run is a deliberate operator action) holds by
|
|
||||||
construction. Only `Run_Stage_Writes::done()` clears `stale`; only
|
|
||||||
`Run_Stage_Rerun_State` (operator-action paths) may demote a done row. `human_modified`
|
|
||||||
is sticky for the life of the run (set when a fork enters execution, surfaced as header/
|
|
||||||
stage chips in the dossier, a board-card chip, and a Review Queue link-chip — the
|
|
||||||
run-list-level visibility the product AC requires).
|
|
||||||
|
|
||||||
**Run reopening.** `Run_State::reopen()` is the atomic terminal→running gate for re-run
|
|
||||||
spend; it RE-SNAPSHOTS `ceiling_usd` from the current setting (a deliberate re-run
|
|
||||||
adopts the operator's current per-run policy, exactly like a new run) and never resets
|
|
||||||
reserved/settled — re-runs are new spend on the same run. Handlers always restore a
|
|
||||||
terminal status (or record failed/halted) so the run-reaper's stuck-running sweep stays
|
|
||||||
meaningful; jobs hold the global generation lock and ride the M1 status transient, so
|
|
||||||
board/dossier polling shows queued → pickup → result and the existing R2/R3 orphan
|
|
||||||
recovery covers dead re-run processes unchanged.
|
|
||||||
|
|
||||||
**Eligibility (guardrail 5).** Posts with status publish/future/private freeze their
|
|
||||||
run server-side (and `Publisher` refuses to touch them: re-run output refreshes only
|
|
||||||
UNPUBLISHED posts, preserving their status — re-runs never publish). Editable stages
|
|
||||||
are the item-scoped writer chat stages (outline/draft/polish); review/publish re-run
|
|
||||||
via rebuild (their inputs are derived), run-level and image stages are excluded with
|
|
||||||
visible in-UI reasons. Phase-2b stages inherit the mechanism when they exist as
|
|
||||||
item-scoped chat calls.
|
|
||||||
|
|
||||||
**F2/F3 (QA M2):** the sidebar consolidates per-stage model/pv/role; image (and other
|
|
||||||
log-only) stages render from generation_log + the `_prautoblogger_image_role`
|
|
||||||
attachment set — wiring the dossier to real data was deliberately chosen over teaching
|
|
||||||
the live image pipeline to write run_stages (that belongs to the Phase-2b restructure).
|
|
||||||
|
|
||||||
## Cross-System LLM Budget Coordination
|
## Cross-System LLM Budget Coordination
|
||||||
|
|
||||||
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
||||||
|
|||||||
137
CHANGELOG.md
137
CHANGELOG.md
@@ -5,143 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
and this project uses [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [0.20.0] - 2026-06-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Edit + single-step re-run (Phase 2 admin M3)** under the five CPO guardrails
|
|
||||||
(cpo seq 8, hard AC). From the Article Dossier, eligible stages can have their
|
|
||||||
input edited and re-run as QUEUED background jobs (chained-cron — the UI never
|
|
||||||
implies synchronous execution; board + dossier polling shows queued → pickup →
|
|
||||||
result).
|
|
||||||
- **Immutable input versions (guardrail 1).** Edits fork into the new INSERT-only
|
|
||||||
`prautoblogger_stage_inputs` table (monotonic versions per stage scope); the
|
|
||||||
executed original in `generation_log.request_json` is never overwritten. The
|
|
||||||
editor locks message structure (count/roles) so every fork stays replayable.
|
|
||||||
- **Visible human-modified audit (guardrail 2).** `run_stages.human_modified` +
|
|
||||||
`run_decisions.human_modified` set when an edited fork enters execution (sticky,
|
|
||||||
never cleared); surfaced as dossier header + stage chips, a board-card chip, and
|
|
||||||
decisions recorded while rebuilding a human-modified item are flagged too.
|
|
||||||
- **Explicit downstream invalidation (guardrail 3).** Re-running a stage marks all
|
|
||||||
downstream chain stages `stale` (new column — rows STAY 'done', so the resume
|
|
||||||
machinery can never silently auto-re-run them). Stale chips render per stage, in
|
|
||||||
the dossier header, and as a Review Queue warning chip linking to the dossier
|
|
||||||
(stale state visible at publish). Downstream re-runs are deliberate: per-stage
|
|
||||||
"Re-run from here" rebuilds the target + downstream from current upstream
|
|
||||||
snapshots via the existing worker resume path.
|
|
||||||
- **Cost governor applies to re-runs (guardrail 4).** Replays go through the normal
|
|
||||||
provider seam: reserve-before-call against the SAME run ledger row.
|
|
||||||
`Run_State::reopen()` re-snapshots the ceiling from the current setting and never
|
|
||||||
resets accumulated spend. The dossier shows a Run Spend card + per-panel spend
|
|
||||||
strip and warns when committed spend reaches the ceiling fraction (filterable,
|
|
||||||
`prautoblogger_rerun_ceiling_warn_fraction`, default 0.8).
|
|
||||||
- **Published posts are frozen (guardrail 5).** publish/future/private lock the run
|
|
||||||
server-side (not just hidden UI); `Publisher` refreshes re-run output onto
|
|
||||||
UNPUBLISHED posts only, preserving their status — a re-run never publishes.
|
|
||||||
- **B1 — `request_json` persisted on every chat LLM call** (QA M2 F1). The provider
|
|
||||||
records the outgoing request BODY pre-dispatch (process-scoped recorder, consume-once);
|
|
||||||
`log_api_call()` writes it to the row. Authorization headers can never be recorded
|
|
||||||
(built separately; body keys whitelisted — covered by tests). Retention rides the
|
|
||||||
existing `prautoblogger_request_json_retention_days` prune; the dossier raw-trace
|
|
||||||
"Request payload" section now renders for new runs.
|
|
||||||
- **Idea seeds.** `Article_Worker` persists the exact `Article_Idea` payload once per
|
|
||||||
item (stage_inputs, source='seed') so re-run-from-here reconstructs the precise idea
|
|
||||||
(re-deriving from post fields could break the item-key hash and duplicate a post).
|
|
||||||
- **Dossier sidebar consolidation (QA M2 F2).** New "Models & Prompts" card: per-stage
|
|
||||||
model + prompt version + agent role at a glance, plus the "Run Spend" card.
|
|
||||||
- **Image sections wired to real data (QA M2 F3).** image_a/image_b (and every
|
|
||||||
log-only stage: llm_research, image_prompt_rewrite, …) now render from their actual
|
|
||||||
generation_log rows, with the post's pipeline attachments (featured/og/square via
|
|
||||||
`_prautoblogger_image_role`) shown as an image grid — chosen/featured highlighted.
|
|
||||||
- **Schema (db 1.3.0).** `run_stages` + `human_modified`/`stale`; `run_decisions` +
|
|
||||||
`human_modified`; new `stage_inputs` table. dbDelta-idempotent via the standard
|
|
||||||
self-healing migration path (admin_init + cron init); half-migrated sites degrade
|
|
||||||
gracefully (done() retries the legacy statement; M3 features no-op until migrated).
|
|
||||||
Uninstall drops the new table and clears the two new cron hooks.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **`PRAutoBlogger_Run_Stage_State` split** (300-line cap, M2 pattern): pipeline write
|
|
||||||
bodies moved verbatim to `Run_Stage_Writes` (public API unchanged via thin proxies);
|
|
||||||
operator-action mutations (restart / mark_stale / demote_to_pending) isolated in
|
|
||||||
`Run_Stage_Rerun_State` — the ONLY code allowed to demote a done row. `done()` now
|
|
||||||
clears `stale` on fresh output.
|
|
||||||
- **Dossier is item-scoped** (M2 correctness fix): stage rows filter to THIS post's
|
|
||||||
item (idea-hash meta), and gen_log rows linked to OTHER posts of a multi-article run
|
|
||||||
are excluded (unlinked run-level rows are kept).
|
|
||||||
- **`Publisher` idempotency short-circuit** now refreshes existing UNPUBLISHED posts
|
|
||||||
with re-run content (title/content/verdict meta; status untouched) instead of
|
|
||||||
silently discarding regenerated output. Published posts remain untouched.
|
|
||||||
- Review Queue rows show a stale-stages warning chip linking to the dossier; board
|
|
||||||
cards show a "Human-modified" chip (single batched query, no N+1).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Dossier "Request payload" raw trace was always empty (request_json never populated —
|
|
||||||
QA M2 F1); image sections always rendered the absent state (QA M2 F3).
|
|
||||||
## [0.19.4] - 2026-06-12
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering
|
|
||||||
`PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error,
|
|
||||||
upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role
|
|
||||||
guard, and successful OG resize happy path. Orchestrator ladder test
|
|
||||||
`test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`,
|
|
||||||
asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is
|
|
||||||
always first.
|
|
||||||
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square)
|
|
||||||
were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout`
|
|
||||||
filter but never consumed by the Imagick renderer. The og renderer positions text from
|
|
||||||
`logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment.
|
|
||||||
Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed;
|
|
||||||
filter users tuning these values had no effect and received no error.
|
|
||||||
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.)
|
|
||||||
- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()`
|
|
||||||
previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`,
|
|
||||||
leaving those columns NULL/empty while the original (correctly stamped) row was deleted.
|
|
||||||
SELECT now includes both columns; INSERT carries them forward from the original row.
|
|
||||||
(Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role
|
|
||||||
empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.)
|
|
||||||
- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware
|
|
||||||
modelSearch API now include up to 200 chars of the response body in the `RuntimeException`
|
|
||||||
message, making diagnosis from logs actionable without re-reproducing the request.
|
|
||||||
(Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".)
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`)
|
|
||||||
is already applied at HEAD (line 454 of the workflow). No changes made.
|
|
||||||
|
|
||||||
## [0.19.3] - 2026-06-12
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Dossier page 403 (P0 hotfix).** `PRAutoBlogger_Dossier_Page::on_register_menu` registered the
|
|
||||||
dossier as a hidden submenu under `prautoblogger-settings` then called `unset($submenu[...])` to
|
|
||||||
hide it from the nav. This is the **hide-by-unset anti-pattern**: at registration time WP records
|
|
||||||
the render callback under hookname `prautoblogger_page_prautoblogger-dossier`; at request time
|
|
||||||
`get_admin_page_parent()` scans `$submenu`, finds the slug absent (it was unset), returns false,
|
|
||||||
and WP recomputes the hookname under the orphan `admin_page_*` namespace -- no handler registered
|
|
||||||
there -- `wp_die(403)`. Same hookname-mismatch class as the board 404 (v0.19.1), different vector.
|
|
||||||
**Fix:** register under parent `options.php` (canonical hidden-page pattern). Hookname is
|
|
||||||
`admin_page_prautoblogger-dossier` at both registration and request time. No `$submenu` mutation
|
|
||||||
required or permitted. Asset enqueue gate updated to match (`admin_page_*` prefix).
|
|
||||||
Deep-link URLs (`admin.php?page=prautoblogger-dossier&post_id=N`) are parent-agnostic and
|
|
||||||
unchanged. Board cards and the post metabox link continue to work without modification.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Regression test upgraded to request-time faithful** (`DossierMenuRegistrationTest`).
|
|
||||||
New `test_request_time_hookname_matches_registration_hookname` simulates
|
|
||||||
`get_admin_page_parent()` against the post-registration `$submenu` state and asserts it
|
|
||||||
equals the registration-time hookname. FAILS on hide-by-unset (4 failures on v0.19.2 code,
|
|
||||||
captured in test evidence). PASSES on options.php-parent. New
|
|
||||||
`test_hide_by_unset_would_fail_request_time_check` documents the broken pattern inline.
|
|
||||||
- **CONVENTIONS.md §Hidden Admin Pages** added: options.php-parent is the only permitted
|
|
||||||
pattern for hidden admin pages; `$submenu` mutation after registration is explicitly
|
|
||||||
prohibited; full incident history (board 404 + dossier 403) and test methodology documented.
|
|
||||||
- **ARCHITECTURE.md §22b** updated with hidden-page convention cross-reference.
|
|
||||||
**§23** updated to reflect options.php-parent registration and the v0.19.2→v0.19.3 fix.
|
|
||||||
- **tests/bootstrap.php**: added `PRAUTOBLOGGER_PLUGIN_URL` and `PRAUTOBLOGGER_VERSION`
|
|
||||||
constants so admin enqueue tests resolve without "Undefined constant" errors.
|
|
||||||
|
|
||||||
## [0.19.2] - 2026-06-12
|
## [0.19.2] - 2026-06-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -502,73 +502,3 @@ public function my_operation( $input ) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
That's it. No config, no feature-flag checks, no try/catch needed.
|
That's it. No config, no feature-flag checks, no try/catch needed.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
|
|
||||||
|
|
||||||
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
|
|
||||||
|
|
||||||
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
|
|
||||||
**options.php-parent pattern** exclusively:
|
|
||||||
|
|
||||||
```php
|
|
||||||
add_submenu_page(
|
|
||||||
'options.php', // parent: built-in WP page; pages hidden by construction
|
|
||||||
__( 'My Page', 'prautoblogger' ), // page title
|
|
||||||
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
|
|
||||||
'manage_options', // capability
|
|
||||||
'my-page-slug', // page slug
|
|
||||||
array( $this, 'render_page' ), // callback
|
|
||||||
);
|
|
||||||
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
|
|
||||||
```
|
|
||||||
|
|
||||||
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
|
|
||||||
|
|
||||||
### Why the hide-by-unset pattern causes 403 (the incident class)
|
|
||||||
|
|
||||||
WordPress computes the page hookname at two distinct moments:
|
|
||||||
|
|
||||||
**Registration time** (inside `add_submenu_page`):
|
|
||||||
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
|
|
||||||
|
|
||||||
**Request time** (inside `wp-admin/admin.php`):
|
|
||||||
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
|
|
||||||
|
|
||||||
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
|
|
||||||
|
|
||||||
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
|
|
||||||
|
|
||||||
### Incident history
|
|
||||||
|
|
||||||
| Version | Symptom | Root cause | Fix |
|
|
||||||
|---------|---------|------------|-----|
|
|
||||||
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
|
|
||||||
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
|
|
||||||
|
|
||||||
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
|
|
||||||
|
|
||||||
### Test pattern
|
|
||||||
|
|
||||||
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
|
|
||||||
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
|
|
||||||
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
|
|
||||||
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
|
|
||||||
Apply the same test strategy to any new hidden admin page.
|
|
||||||
|
|
||||||
### Asset enqueue gate
|
|
||||||
|
|
||||||
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
|
|
||||||
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
|
|
||||||
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// options.php parent → 'admin_page_{slug}'
|
|
||||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
|
||||||
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ... enqueue
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
/**
|
|
||||||
* PRAutoBlogger dossier — edit + re-run styles (M3, v0.20.0).
|
|
||||||
* Extends dossier.css (kept separate: dossier.css predates the
|
|
||||||
* 300-line discipline and is not grown further).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ── Audit chips (stale / human-edited / attempt) ─────────────────── */
|
|
||||||
.prab-chip {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.6;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-chip--stale {
|
|
||||||
background: #fcf0e4;
|
|
||||||
color: #8a4a0b;
|
|
||||||
border: 1px solid #e8c9a8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-chip--human {
|
|
||||||
background: #e9f2f4;
|
|
||||||
color: #1b5e6b;
|
|
||||||
border: 1px solid #b8d8de;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-chip--attempt {
|
|
||||||
background: #f0f0f1;
|
|
||||||
color: #50575e;
|
|
||||||
border: 1px solid #dcdcde;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-chip--chosen {
|
|
||||||
background: #edf7ed;
|
|
||||||
color: #1e6b2e;
|
|
||||||
border: 1px solid #b9dfc0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-chip--header {
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-stage--stale {
|
|
||||||
border-left: 3px solid #d98c2b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Stage footer actions ─────────────────────────────────────────── */
|
|
||||||
.prab-stage-rerun-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px dashed #e2dcd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-unavailable {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
font-style: italic;
|
|
||||||
max-width: 46em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-rerun-from--stale {
|
|
||||||
border-color: #d98c2b;
|
|
||||||
color: #8a4a0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Edit panel ───────────────────────────────────────────────────── */
|
|
||||||
.prab-edit-panel {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: #fbf8f2;
|
|
||||||
border: 1px solid #e2dcd2;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-copy {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #50575e;
|
|
||||||
max-width: 60em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-spend {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #50575e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-spend--warn,
|
|
||||||
.prab-spend-warning {
|
|
||||||
color: #8a2a0b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-run-spend--warn {
|
|
||||||
border-left: 3px solid #c9501f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-model {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
font-family: Consolas, Monaco, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-message {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-role {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: #8c8f94;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-textarea {
|
|
||||||
width: 100%;
|
|
||||||
font-family: Consolas, Monaco, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-forkinfo {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-feedback {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #1e6b2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-edit-feedback--error {
|
|
||||||
color: #b32d2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Image grid (F3) ──────────────────────────────────────────────── */
|
|
||||||
.prab-image-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-image-card {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-image-card img {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e2dcd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-image-card--chosen img {
|
|
||||||
border: 2px solid #1b8a92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-image-card figcaption {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #50575e;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sidebar models card (F2) ─────────────────────────────────────── */
|
|
||||||
.prab-models-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-models-table th {
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 6px 4px 0;
|
|
||||||
vertical-align: top;
|
|
||||||
color: #50575e;
|
|
||||||
width: 38%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-models-table td {
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-models-model {
|
|
||||||
display: block;
|
|
||||||
font-family: Consolas, Monaco, monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prab-models-meta {
|
|
||||||
display: block;
|
|
||||||
color: #8c8f94;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Board card human-modified dot ────────────────────────────────── */
|
|
||||||
.prab-card-human-chip {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 4px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 9px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #e9f2f4;
|
|
||||||
color: #1b5e6b;
|
|
||||||
border: 1px solid #b8d8de;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Review queue stale warning ───────────────────────────────────── */
|
|
||||||
.prab-review-stale-chip {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 6px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 9px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #fcf0e4;
|
|
||||||
color: #8a4a0b;
|
|
||||||
border: 1px solid #e8c9a8;
|
|
||||||
}
|
|
||||||
@@ -69,11 +69,6 @@
|
|||||||
|
|
||||||
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
||||||
|
|
||||||
// v0.20.0: human-modified runs are visually distinct at run-list level.
|
|
||||||
if ( card.human_modified ) {
|
|
||||||
html += '<span class="prab-card-human-chip">' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( card.cost_total ) {
|
if ( card.cost_total ) {
|
||||||
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
||||||
html += escHtml( formatCost( card.cost_total ) );
|
html += escHtml( formatCost( card.cost_total ) );
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
/**
|
|
||||||
* PRAutoBlogger Article Dossier — edit + re-run actions (M3, v0.20.0).
|
|
||||||
*
|
|
||||||
* Chained-cron UX contract (CPO hard constraint): clicking a re-run
|
|
||||||
* button QUEUES a background job — the UI says "queued", starts the
|
|
||||||
* stage-status poll, and renders pickup/result as the rows change.
|
|
||||||
* Nothing here implies synchronous execution.
|
|
||||||
*
|
|
||||||
* Vanilla JS, no build step. Config injected via wp_localize_script
|
|
||||||
* ('prabDossierEdit': ajaxUrl, nonce, postId, pollInterval, strings).
|
|
||||||
*
|
|
||||||
* @see admin/class-dossier-actions.php — The endpoints this calls.
|
|
||||||
* @see admin/class-dossier-page.php — Enqueue + localization.
|
|
||||||
*/
|
|
||||||
( function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var cfg = window.prabDossierEdit || null;
|
|
||||||
|
|
||||||
function post( action, fields ) {
|
|
||||||
var data = new window.FormData();
|
|
||||||
data.append( 'action', action );
|
|
||||||
data.append( 'nonce', cfg.nonce );
|
|
||||||
data.append( 'post_id', cfg.postId );
|
|
||||||
Object.keys( fields || {} ).forEach( function ( key ) {
|
|
||||||
data.append( key, fields[ key ] );
|
|
||||||
} );
|
|
||||||
return window.fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data } )
|
|
||||||
.then( function ( res ) { return res.json(); } );
|
|
||||||
}
|
|
||||||
|
|
||||||
function feedback( panel, message, isError ) {
|
|
||||||
var el = panel.querySelector( '[data-feedback]' );
|
|
||||||
if ( el ) {
|
|
||||||
el.textContent = message || '';
|
|
||||||
el.classList.toggle( 'prab-edit-feedback--error', !! isError );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectionFor( el ) {
|
|
||||||
var node = el;
|
|
||||||
while ( node && ! node.classList.contains( 'prab-dossier-section--stage' ) ) {
|
|
||||||
node = node.parentElement;
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage-status polling (queued → pickup → result) ────────────────
|
|
||||||
var pollTimer = null;
|
|
||||||
var sawActivity = false;
|
|
||||||
|
|
||||||
function applyStageState( state ) {
|
|
||||||
var selector = '.prab-dossier-section--stage[data-stage="' + state.stage + '"][data-agent-role="' + state.agent_role + '"]';
|
|
||||||
var section = document.querySelector( selector );
|
|
||||||
if ( ! section ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var pill = section.querySelector( '.prab-stage-status-pill' );
|
|
||||||
if ( pill ) {
|
|
||||||
pill.className = 'prab-stage-status-pill prab-stage-status-pill--' + state.status;
|
|
||||||
pill.textContent = state.status.charAt( 0 ).toUpperCase() + state.status.slice( 1 );
|
|
||||||
}
|
|
||||||
var staleChip = section.querySelector( '[data-chip="stale"]' );
|
|
||||||
if ( staleChip ) {
|
|
||||||
staleChip.hidden = ! state.stale;
|
|
||||||
}
|
|
||||||
section.classList.toggle( 'prab-stage--stale', !! state.stale );
|
|
||||||
var humanChip = section.querySelector( '[data-chip="human"]' );
|
|
||||||
if ( humanChip ) {
|
|
||||||
humanChip.hidden = ! state.human_modified;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function poll() {
|
|
||||||
post( 'prautoblogger_dossier_stage_status', {} ).then( function ( res ) {
|
|
||||||
if ( ! res || ! res.success || ! res.data ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var anyRunning = false;
|
|
||||||
var anyStale = false;
|
|
||||||
var anyHuman = false;
|
|
||||||
( res.data.stages || [] ).forEach( function ( state ) {
|
|
||||||
applyStageState( state );
|
|
||||||
if ( 'running' === state.status || 'pending' === state.status ) {
|
|
||||||
anyRunning = true;
|
|
||||||
}
|
|
||||||
anyStale = anyStale || !! state.stale;
|
|
||||||
anyHuman = anyHuman || !! state.human_modified;
|
|
||||||
} );
|
|
||||||
var headerHuman = document.querySelector( '[data-header-chip="human"]' );
|
|
||||||
if ( headerHuman ) {
|
|
||||||
headerHuman.hidden = ! anyHuman;
|
|
||||||
}
|
|
||||||
var headerStale = document.querySelector( '[data-header-chip="stale"]' );
|
|
||||||
if ( headerStale ) {
|
|
||||||
headerStale.hidden = ! anyStale;
|
|
||||||
}
|
|
||||||
|
|
||||||
var terminal = -1 === [ 'running', 'pending' ].indexOf( res.data.run_status );
|
|
||||||
if ( anyRunning || ! terminal ) {
|
|
||||||
sawActivity = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Run settled after activity we watched: re-render the dossier
|
|
||||||
// fresh (server-rendered outputs, receipts, fork info).
|
|
||||||
if ( sawActivity ) {
|
|
||||||
window.clearInterval( pollTimer );
|
|
||||||
pollTimer = null;
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
} ).catch( function () { /* transient poll errors are ignored */ } );
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
sawActivity = true;
|
|
||||||
if ( ! pollTimer ) {
|
|
||||||
pollTimer = window.setInterval( poll, cfg.pollInterval * 1000 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wiring ──────────────────────────────────────────────────────────
|
|
||||||
document.addEventListener( 'DOMContentLoaded', function () {
|
|
||||||
if ( ! cfg ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit panel toggles.
|
|
||||||
document.querySelectorAll( '.prab-edit-toggle' ).forEach( function ( btn ) {
|
|
||||||
btn.addEventListener( 'click', function () {
|
|
||||||
var panel = document.getElementById( btn.getAttribute( 'aria-controls' ) );
|
|
||||||
if ( ! panel ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var open = btn.getAttribute( 'aria-expanded' ) === 'true';
|
|
||||||
panel.hidden = open;
|
|
||||||
btn.setAttribute( 'aria-expanded', open ? 'false' : 'true' );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
|
|
||||||
// Save fork.
|
|
||||||
document.querySelectorAll( '.prab-edit-save' ).forEach( function ( btn ) {
|
|
||||||
btn.addEventListener( 'click', function () {
|
|
||||||
var panel = btn.closest( '.prab-edit-panel' );
|
|
||||||
var messages = [];
|
|
||||||
panel.querySelectorAll( '.prab-edit-textarea' ).forEach( function ( area ) {
|
|
||||||
messages.push( { role: area.getAttribute( 'data-role' ), content: area.value } );
|
|
||||||
} );
|
|
||||||
btn.disabled = true;
|
|
||||||
feedback( panel, cfg.strings.saving, false );
|
|
||||||
post( 'prautoblogger_dossier_save_input', {
|
|
||||||
stage: panel.getAttribute( 'data-stage' ),
|
|
||||||
agent_role: panel.getAttribute( 'data-agent-role' ),
|
|
||||||
messages: JSON.stringify( messages )
|
|
||||||
} ).then( function ( res ) {
|
|
||||||
btn.disabled = false;
|
|
||||||
if ( res && res.success ) {
|
|
||||||
feedback( panel, res.data.message, false );
|
|
||||||
var rerunBtn = panel.querySelector( '.prab-edit-rerun' );
|
|
||||||
if ( rerunBtn ) {
|
|
||||||
rerunBtn.disabled = false;
|
|
||||||
}
|
|
||||||
var info = panel.querySelector( '[data-forkinfo]' );
|
|
||||||
if ( info && res.data.version ) {
|
|
||||||
info.textContent = 'v' + res.data.version;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
|
|
||||||
}
|
|
||||||
} ).catch( function () {
|
|
||||||
btn.disabled = false;
|
|
||||||
feedback( panel, cfg.strings.error, true );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
|
|
||||||
// Replay latest fork (queued).
|
|
||||||
document.querySelectorAll( '.prab-edit-rerun' ).forEach( function ( btn ) {
|
|
||||||
btn.addEventListener( 'click', function () {
|
|
||||||
var panel = btn.closest( '.prab-edit-panel' );
|
|
||||||
if ( ! window.confirm( cfg.strings.confirmReplay ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btn.disabled = true;
|
|
||||||
post( 'prautoblogger_dossier_rerun_stage', {
|
|
||||||
stage: panel.getAttribute( 'data-stage' ),
|
|
||||||
agent_role: panel.getAttribute( 'data-agent-role' )
|
|
||||||
} ).then( function ( res ) {
|
|
||||||
if ( res && res.success ) {
|
|
||||||
feedback( panel, res.data.message, false );
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true );
|
|
||||||
}
|
|
||||||
} ).catch( function () {
|
|
||||||
btn.disabled = false;
|
|
||||||
feedback( panel, cfg.strings.error, true );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
|
|
||||||
// Re-run from here (queued rebuild).
|
|
||||||
document.querySelectorAll( '.prab-rerun-from' ).forEach( function ( btn ) {
|
|
||||||
btn.addEventListener( 'click', function () {
|
|
||||||
var section = sectionFor( btn );
|
|
||||||
if ( ! window.confirm( cfg.strings.confirmRerunFrom ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = cfg.strings.queued;
|
|
||||||
post( 'prautoblogger_dossier_rerun_from', {
|
|
||||||
stage: btn.getAttribute( 'data-stage' ),
|
|
||||||
agent_role: section ? section.getAttribute( 'data-agent-role' ) : ''
|
|
||||||
} ).then( function ( res ) {
|
|
||||||
if ( res && res.success ) {
|
|
||||||
startPolling();
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = cfg.strings.rerunFromLabel;
|
|
||||||
window.alert( ( res && res.data && res.data.message ) || cfg.strings.error );
|
|
||||||
}
|
|
||||||
} ).catch( function () {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = cfg.strings.rerunFromLabel;
|
|
||||||
window.alert( cfg.strings.error );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
} );
|
|
||||||
|
|
||||||
// A stage already running (e.g. revisit mid-job): poll from load.
|
|
||||||
if ( document.querySelector( '.prab-stage-status-pill--running' ) ) {
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
}() );
|
|
||||||
@@ -127,7 +127,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->flag_human_modified( $cards );
|
return $cards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,48 +183,6 @@ 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;
|
return $cards;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ class PRAutoBlogger_Board_Page {
|
|||||||
'edit' => __( 'Edit', 'prautoblogger' ),
|
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||||
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||||
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
||||||
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,15 +16,6 @@ declare(strict_types=1);
|
|||||||
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
|
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
|
||||||
* Amortized research rows (pv=null, role='') render gracefully.
|
* 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().
|
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
|
||||||
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
|
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
|
||||||
* PRAutoBlogger_Run_Stage_State.
|
* PRAutoBlogger_Run_Stage_State.
|
||||||
@@ -64,16 +55,6 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
|||||||
'stages' => array(),
|
'stages' => array(),
|
||||||
'decisions' => array(),
|
'decisions' => array(),
|
||||||
'gen_log_index' => 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 ) {
|
if ( $post_id <= 0 ) {
|
||||||
@@ -101,40 +82,11 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
|||||||
return $base;
|
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['has_run'] = true;
|
||||||
$base['run'] = $run;
|
$base['run'] = $run;
|
||||||
$base['stages'] = $this->get_stage_rows( $run_id, $item_key );
|
$base['stages'] = $this->get_stage_rows( $run_id );
|
||||||
$base['decisions'] = $this->get_decision_rows( $run_id );
|
$base['decisions'] = $this->get_decision_rows( $run_id );
|
||||||
$base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id );
|
$base['gen_log_index'] = $this->log_query->get_by_run( $run_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;
|
return $base;
|
||||||
}
|
}
|
||||||
@@ -160,14 +112,12 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch run_stages rows for a run, indexed by stage name — scoped to
|
* Fetch all run_stages rows for a run, indexed by stage name.
|
||||||
* 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.
|
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
|
||||||
*/
|
*/
|
||||||
private function get_stage_rows( string $run_id, string $item_key = '' ): array {
|
private function get_stage_rows( string $run_id ): array {
|
||||||
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
@@ -176,22 +126,11 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
|||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||||
// v0.20.0: scope to THIS post's item + run-level rows. When the
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
// idea-hash meta is absent (pre-v0.18 posts) fall back to the
|
$rows = $wpdb->get_results(
|
||||||
// unfiltered M2 behavior (single-item runs are unaffected).
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||||
if ( '' !== $item_key ) {
|
ARRAY_A
|
||||||
// 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();
|
$index = array();
|
||||||
foreach ( ( $rows ?? array() ) as $row ) {
|
foreach ( ( $rows ?? array() ) as $row ) {
|
||||||
@@ -206,29 +145,6 @@ class PRAutoBlogger_Dossier_Data_Assembler {
|
|||||||
return $index;
|
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.
|
* Extract the text output from a run_stages meta_json blob.
|
||||||
* Returns null when meta_json is NULL (pruned) or absent.
|
* Returns null when meta_json is NULL (pruned) or absent.
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,25 +6,10 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Registers and renders the Article Dossier admin page (M2).
|
* Registers and renders the Article Dossier admin page (M2).
|
||||||
*
|
*
|
||||||
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
|
* The dossier is link-accessed -- it is NOT a visible submenu item. It registers
|
||||||
* the canonical hidden-admin-page pattern: register under parent 'options.php'
|
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
|
||||||
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
|
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
|
||||||
* BOTH registration time AND request time. The page is hidden from nav by
|
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
|
||||||
* construction (options.php is a built-in WP page with no visible submenu block)
|
|
||||||
* so no $submenu mutation is needed or permitted.
|
|
||||||
*
|
|
||||||
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
|
|
||||||
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
|
|
||||||
* post-registration unset($submenu['prautoblogger-settings'][$idx])
|
|
||||||
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
|
|
||||||
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
|
|
||||||
* → no callback registered there → wp_die(403).
|
|
||||||
* See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident
|
|
||||||
* history (board 404 v0.19.1, dossier 403 v0.19.2).
|
|
||||||
*
|
|
||||||
* Board cards and post-metabox deep-link here via
|
|
||||||
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
|
|
||||||
* (they reference only the page slug) so they are unchanged by this fix.
|
|
||||||
*
|
*
|
||||||
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
||||||
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
||||||
@@ -34,44 +19,46 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
||||||
* @see templates/admin/dossier-page.php -- HTML template.
|
* @see templates/admin/dossier-page.php -- HTML template.
|
||||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
* @see ARCHITECTURE.md -- §Dossier (M2).
|
||||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Dossier_Page {
|
class PRAutoBlogger_Dossier_Page {
|
||||||
|
|
||||||
/** Admin page slug. */
|
/** Admin page slug. */
|
||||||
public const PAGE_SLUG = 'prautoblogger-dossier';
|
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||||
|
|
||||||
/**
|
|
||||||
* Parent slug used at registration.
|
|
||||||
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
|
|
||||||
* to `admin_page_{slug}` at both registration time and request time, so there is
|
|
||||||
* no hookname mismatch and no need to manipulate $submenu.
|
|
||||||
*/
|
|
||||||
public const PARENT_SLUG = 'options.php';
|
|
||||||
|
|
||||||
/** Nonce action for any future stateful actions. */
|
/** Nonce action for any future stateful actions. */
|
||||||
public const NONCE_ACTION = 'prautoblogger_dossier';
|
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the dossier as a hidden admin page.
|
* Register the dossier as a hidden submenu page.
|
||||||
*
|
*
|
||||||
* Uses the canonical options.php-parent pattern so the hookname is stable at
|
* Hidden = not visible in the menu, but reachable by URL.
|
||||||
* both registration time and request time. No $submenu manipulation required
|
* Priority 12 ensures add_menu_page() (priority 10) has populated
|
||||||
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
|
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function on_register_menu(): void {
|
public function on_register_menu(): void {
|
||||||
add_submenu_page(
|
add_submenu_page(
|
||||||
self::PARENT_SLUG,
|
'prautoblogger-settings',
|
||||||
__( 'Article Dossier', 'prautoblogger' ),
|
__( 'Article Dossier', 'prautoblogger' ),
|
||||||
__( 'Dossier', 'prautoblogger' ),
|
__( 'Dossier', 'prautoblogger' ),
|
||||||
'manage_options',
|
'manage_options',
|
||||||
self::PAGE_SLUG,
|
self::PAGE_SLUG,
|
||||||
array( $this, 'render_page' )
|
array( $this, 'render_page' )
|
||||||
);
|
);
|
||||||
// No $submenu mutation. options.php-parent pages are hidden by construction.
|
|
||||||
|
// Remove from visible submenu so it doesn't clutter the nav.
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional hidden-submenu pattern.
|
||||||
|
global $submenu;
|
||||||
|
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||||
|
foreach ( $submenu['prautoblogger-settings'] as $idx => $item ) {
|
||||||
|
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||||
|
unset( $submenu['prautoblogger-settings'][ $idx ] );
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +71,7 @@ class PRAutoBlogger_Dossier_Page {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
|
||||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||||
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
|
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
|
||||||
$dossier = $assembler->assemble( $post_id );
|
$dossier = $assembler->assemble( $post_id );
|
||||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
|
||||||
@@ -93,14 +80,11 @@ class PRAutoBlogger_Dossier_Page {
|
|||||||
/**
|
/**
|
||||||
* Enqueue dossier-specific CSS + JS.
|
* Enqueue dossier-specific CSS + JS.
|
||||||
*
|
*
|
||||||
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
|
|
||||||
*
|
|
||||||
* @param string $hook_suffix Current admin page hook suffix.
|
* @param string $hook_suffix Current admin page hook suffix.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
|
$dossier_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||||
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
|
|
||||||
if ( $hook_suffix !== $dossier_hook ) {
|
if ( $hook_suffix !== $dossier_hook ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,50 +103,11 @@ class PRAutoBlogger_Dossier_Page {
|
|||||||
PRAUTOBLOGGER_VERSION,
|
PRAUTOBLOGGER_VERSION,
|
||||||
true
|
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' ),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the URL to the dossier for a given post.
|
* Build the URL to the dossier for a given post.
|
||||||
*
|
*
|
||||||
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
|
|
||||||
* This method is unchanged from v0.19.2 -- only the registration parent changed.
|
|
||||||
*
|
|
||||||
* @param int $post_id WordPress post ID.
|
* @param int $post_id WordPress post ID.
|
||||||
* @return string Admin URL to the dossier page.
|
* @return string Admin URL to the dossier page.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,12 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
*
|
*
|
||||||
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.3.0).
|
* Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.2.0).
|
||||||
*
|
*
|
||||||
* What: Owns the six pipeline substrate tables — the versioned prompt
|
* What: Owns the five v0.18.0 substrate tables — the versioned prompt
|
||||||
* registry (`prompts`), the audit child tables (`run_sources`,
|
* registry (`prompts`), the audit child tables (`run_sources`,
|
||||||
* `run_decisions`), and the run ledger / state machine tables
|
* `run_decisions`), and the run ledger / state machine tables
|
||||||
* (`runs`, `run_stages`), and the immutable stage-input version
|
* (`runs`, `run_stages`). Kept separate from the original
|
||||||
* 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
|
* PRAutoBlogger_Schema_Installer so both classes stay under the
|
||||||
* 300-line cap and the v1.1.0 schema remains byte-stable.
|
* 300-line cap and the v1.1.0 schema remains byte-stable.
|
||||||
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
|
* Who triggers it: PRAutoBlogger_Activator::activate() — on activation and
|
||||||
@@ -35,7 +33,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
|||||||
* plugin's forward-only schema policy. Safe to re-run on every
|
* plugin's forward-only schema policy. Safe to re-run on every
|
||||||
* version mismatch.
|
* version mismatch.
|
||||||
*
|
*
|
||||||
* Side effects: up to six CREATE TABLE / ALTER TABLE statements.
|
* Side effects: up to five CREATE TABLE / ALTER TABLE statements.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -93,7 +91,6 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
|||||||
verdict VARCHAR(50) NOT NULL,
|
verdict VARCHAR(50) NOT NULL,
|
||||||
rationale TEXT DEFAULT NULL,
|
rationale TEXT DEFAULT NULL,
|
||||||
citation_score FLOAT DEFAULT NULL,
|
citation_score FLOAT DEFAULT NULL,
|
||||||
human_modified TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY run_id (run_id)
|
KEY run_id (run_id)
|
||||||
@@ -133,8 +130,6 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
|||||||
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0,
|
||||||
meta_json LONGTEXT DEFAULT NULL,
|
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,
|
started_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
finished_at DATETIME DEFAULT NULL,
|
finished_at DATETIME DEFAULT NULL,
|
||||||
@@ -143,37 +138,10 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
|||||||
KEY status_updated (status, updated_at)
|
KEY status_updated (status, updated_at)
|
||||||
) {$charset_collate};";
|
) {$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_prompts );
|
||||||
dbDelta( $sql_run_sources );
|
dbDelta( $sql_run_sources );
|
||||||
dbDelta( $sql_run_decisions );
|
dbDelta( $sql_run_decisions );
|
||||||
dbDelta( $sql_runs );
|
dbDelta( $sql_runs );
|
||||||
dbDelta( $sql_run_stages );
|
dbDelta( $sql_run_stages );
|
||||||
dbDelta( $sql_stage_inputs );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,11 +141,6 @@ class PRAutoBlogger {
|
|||||||
new PRAutoBlogger_Opik_Settings();
|
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_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_Research_Reaper', 'on_cron' ) );
|
||||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
||||||
@@ -179,13 +174,6 @@ class PRAutoBlogger {
|
|||||||
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
|
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' ) );
|
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();
|
$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_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' ) );
|
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );
|
||||||
|
|||||||
@@ -58,13 +58,6 @@ class PRAutoBlogger_Article_Worker {
|
|||||||
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
|
$run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' );
|
||||||
$item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea );
|
$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(
|
$result = array(
|
||||||
'generated' => 0,
|
'generated' => 0,
|
||||||
'published' => 0,
|
'published' => 0,
|
||||||
|
|||||||
@@ -125,37 +125,6 @@ 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). */
|
/** Reset per-request caches (tests). */
|
||||||
public static function flush_cache(): void {
|
public static function flush_cache(): void {
|
||||||
self::$tables_ok = null;
|
self::$tables_ok = null;
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
/**
|
/**
|
||||||
* Log an API call with its token usage and estimated cost.
|
* Log an API call with its token usage and estimated cost.
|
||||||
*
|
*
|
||||||
* Side effects: database insert into prab_generation_log. Consumes the
|
* Side effects: database insert into prab_generation_log.
|
||||||
* 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 int|null $post_id WordPress post ID (null during generation, set later).
|
||||||
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
|
* @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map).
|
||||||
@@ -119,7 +117,6 @@ class PRAutoBlogger_Cost_Tracker {
|
|||||||
'estimated_cost' => $cost,
|
'estimated_cost' => $cost,
|
||||||
'response_status' => $response_status,
|
'response_status' => $response_status,
|
||||||
'error_message' => $error_message,
|
'error_message' => $error_message,
|
||||||
'request_json' => PRAutoBlogger_Request_Recorder::consume(),
|
|
||||||
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ),
|
||||||
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
|
'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ),
|
||||||
'created_at' => current_time( 'mysql' ),
|
'created_at' => current_time( 'mysql' ),
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ class PRAutoBlogger_Image_Composer_Layout {
|
|||||||
* plugin; sizes are px. Values were tuned against rendered samples (see
|
* plugin; sizes are px. Values were tuned against rendered samples (see
|
||||||
* PR #thread 2026-06-image-composer assets).
|
* PR #thread 2026-06-image-composer assets).
|
||||||
*
|
*
|
||||||
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
|
|
||||||
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
|
|
||||||
* and centered x positioning respectively, so those keys had no consumer.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
||||||
*/
|
*/
|
||||||
public static function defaults(): array {
|
public static function defaults(): array {
|
||||||
@@ -64,6 +60,7 @@ class PRAutoBlogger_Image_Composer_Layout {
|
|||||||
'caption_size' => 32,
|
'caption_size' => 32,
|
||||||
'caption_color' => '#FFFFFF',
|
'caption_color' => '#FFFFFF',
|
||||||
'caption_gap' => 24,
|
'caption_gap' => 24,
|
||||||
|
'caption_margin_right' => 40,
|
||||||
'caption_max_lines' => 2,
|
'caption_max_lines' => 2,
|
||||||
'caption_chars_per_line' => 52,
|
'caption_chars_per_line' => 52,
|
||||||
'caption_line_height' => 1.3,
|
'caption_line_height' => 1.3,
|
||||||
@@ -82,6 +79,7 @@ class PRAutoBlogger_Image_Composer_Layout {
|
|||||||
'caption_color' => self::COLOR_INK,
|
'caption_color' => self::COLOR_INK,
|
||||||
'caption_max_lines' => 3,
|
'caption_max_lines' => 3,
|
||||||
'caption_chars_per_line' => 36,
|
'caption_chars_per_line' => 36,
|
||||||
|
'caption_side_padding' => 64,
|
||||||
'caption_line_height' => 1.3,
|
'caption_line_height' => 1.3,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,9 +91,7 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
* are generated. Its cost is logged with the run_id but no post_id. After all
|
* are generated. Its cost is logged with the run_id but no post_id. After all
|
||||||
* articles finish, this method divides the research cost evenly and inserts
|
* articles finish, this method divides the research cost evenly and inserts
|
||||||
* one 'llm_research' row per article so the cost breakdown popover includes
|
* one 'llm_research' row per article so the cost breakdown popover includes
|
||||||
* the amortized research overhead. The audit fields prompt_version and
|
* the amortized research overhead.
|
||||||
* agent_role are carried from the original research row (v0.19.4 fix;
|
|
||||||
* prior builds left them NULL/empty — see phase-1 thread seq 49).
|
|
||||||
*
|
*
|
||||||
* Side effects: database SELECT, INSERT, DELETE on generation_log.
|
* Side effects: database SELECT, INSERT, DELETE on generation_log.
|
||||||
*
|
*
|
||||||
@@ -111,7 +109,7 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
$research_row = $wpdb->get_row(
|
$research_row = $wpdb->get_row(
|
||||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role
|
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
@@ -160,8 +158,6 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
'estimated_cost' => $amortized_cost,
|
'estimated_cost' => $amortized_cost,
|
||||||
'response_status' => 'success',
|
'response_status' => 'success',
|
||||||
'error_message' => '',
|
'error_message' => '',
|
||||||
'prompt_version' => $research_row->prompt_version,
|
|
||||||
'agent_role' => $research_row->agent_role ?? '',
|
|
||||||
'created_at' => current_time( 'mysql' ),
|
'created_at' => current_time( 'mysql' ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -107,11 +107,11 @@ class PRAutoBlogger_Publisher {
|
|||||||
if ( null !== $run_id && '' !== $run_id ) {
|
if ( null !== $run_id && '' !== $run_id ) {
|
||||||
$existing_id = $this->find_existing_post( $run_id, $idea_hash );
|
$existing_id = $this->find_existing_post( $run_id, $idea_hash );
|
||||||
if ( $existing_id > 0 ) {
|
if ( $existing_id > 0 ) {
|
||||||
// v0.20.0: a re-run's regenerated content must land on the
|
PRAutoBlogger_Logger::instance()->info(
|
||||||
// existing UNPUBLISHED post (otherwise re-runs silently
|
sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ),
|
||||||
// discard their output). Published posts stay frozen
|
'publisher'
|
||||||
// (CPO guardrail 5) — refresh skips them untouched.
|
);
|
||||||
return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review );
|
return $existing_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,60 +157,6 @@ class PRAutoBlogger_Publisher {
|
|||||||
return $post_id;
|
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.
|
* Build post_meta array for generation metadata.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
<?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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<?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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,9 +16,8 @@ declare(strict_types=1);
|
|||||||
* ledger/state activity for 2× the expected run wall-clock are
|
* ledger/state activity for 2× the expected run wall-clock are
|
||||||
* marked `failed` and their non-done stages reaped to `failed`;
|
* marked `failed` and their non-done stages reaped to `failed`;
|
||||||
* such runs render as "incomplete" via incomplete_runs().
|
* such runs render as "incomplete" via incomplete_runs().
|
||||||
* 3. Retention — `request_json` on generation_log, stage output
|
* 3. Retention — `request_json` on generation_log and stage output
|
||||||
* payloads (`meta_json` on run_stages) are NULLed after R days;
|
* 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`
|
* R comes from the `prautoblogger_request_json_retention_days`
|
||||||
* SETTING (default constant 14; 0 = keep forever) — never a
|
* SETTING (default constant 14; 0 = keep forever) — never a
|
||||||
* literal at the point of use.
|
* literal at the point of use.
|
||||||
@@ -266,11 +265,6 @@ class PRAutoBlogger_Run_Reaper {
|
|||||||
$pruned += is_numeric( $result ) ? (int) $result : 0;
|
$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;
|
return $pruned;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,17 +16,9 @@ declare(strict_types=1);
|
|||||||
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role=''
|
* 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.
|
* 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-state.php — Run-level lifecycle + ledger row.
|
||||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||||
* @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design.
|
* @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Run_Stage_State {
|
class PRAutoBlogger_Run_Stage_State {
|
||||||
|
|
||||||
@@ -81,7 +73,6 @@ class PRAutoBlogger_Run_Stage_State {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a stage as running. Sticky: done stages are never demoted.
|
* 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 $run_id Run UUID.
|
||||||
* @param string $stage Stage name (see Stage_Display_Map).
|
* @param string $stage Stage name (see Stage_Display_Map).
|
||||||
@@ -89,13 +80,35 @@ class PRAutoBlogger_Run_Stage_State {
|
|||||||
* @param string $item_key Article scope ('' for run-level stages).
|
* @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 {
|
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||||
PRAutoBlogger_Run_Stage_Writes::start( $run_id, $stage, $agent_role, $item_key );
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a stage done and persist its output for resume-without-recharge.
|
* 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 $run_id Run UUID.
|
||||||
* @param string $stage Stage name.
|
* @param string $stage Stage name.
|
||||||
@@ -105,12 +118,41 @@ class PRAutoBlogger_Run_Stage_State {
|
|||||||
* @param float $cost_usd Cost attributed to this stage.
|
* @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 {
|
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
|
||||||
PRAutoBlogger_Run_Stage_Writes::done( $run_id, $stage, $agent_role, $item_key, $output, $cost_usd );
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a stage failed (does not demote a done stage). Delegates to
|
* Mark a stage failed (does not demote a done stage).
|
||||||
* Run_Stage_Writes (v0.20.0 split — behavior unchanged).
|
|
||||||
*
|
*
|
||||||
* @param string $run_id Run UUID.
|
* @param string $run_id Run UUID.
|
||||||
* @param string $stage Stage name.
|
* @param string $stage Stage name.
|
||||||
@@ -118,7 +160,26 @@ class PRAutoBlogger_Run_Stage_State {
|
|||||||
* @param string $item_key Article scope.
|
* @param string $item_key Article scope.
|
||||||
*/
|
*/
|
||||||
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||||
PRAutoBlogger_Run_Stage_Writes::fail( $run_id, $stage, $agent_role, $item_key );
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,7 +196,7 @@ class PRAutoBlogger_Run_Stage_State {
|
|||||||
if ( '' === $run_id || ! self::is_available() ) {
|
if ( '' === $run_id || ! self::is_available() ) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$agent_role = PRAutoBlogger_Run_Stage_Writes::resolve_role( $stage, $agent_role );
|
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = self::table_name();
|
$table = self::table_name();
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
@@ -204,68 +265,49 @@ class PRAutoBlogger_Run_Stage_State {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail every still-open stage of one item. Done stages are untouched.
|
* 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 $run_id Run UUID.
|
||||||
* @param string $item_key Stage item key ('' = run-level stages).
|
* @param string $item_key Stage item key ('' = run-level stages).
|
||||||
*/
|
*/
|
||||||
public static function fail_open_for_item( string $run_id, string $item_key ): void {
|
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() ) {
|
if ( '' === $run_id || ! self::is_available() ) {
|
||||||
return array();
|
return;
|
||||||
}
|
}
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = self::table_name();
|
$table = self::table_name();
|
||||||
$sql = $include_run_level
|
$now = current_time( 'mysql' );
|
||||||
? "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC"
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
: "SELECT * FROM {$table} WHERE run_id = %s AND item_key = %s ORDER BY id ASC";
|
$wpdb->query(
|
||||||
// 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(
|
$wpdb->prepare(
|
||||||
"SELECT DISTINCT run_id FROM {$table} WHERE stale = 1 AND run_id IN ({$placeholders})",
|
"UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s
|
||||||
$run_ids
|
WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')",
|
||||||
|
$now,
|
||||||
|
$now,
|
||||||
|
$run_id,
|
||||||
|
$item_key
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return is_array( $rows ) ? array_map( 'strval', $rows ) : array();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset per-request caches (tests). */
|
/** Reset per-request caches (tests). */
|
||||||
public static function flush_cache(): void {
|
public static function flush_cache(): void {
|
||||||
self::$table_ok = null;
|
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 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
<?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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -252,41 +252,6 @@ class PRAutoBlogger_Run_State {
|
|||||||
return is_array( $rows ) ? $rows : array();
|
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). */
|
/** Reset per-request caches (tests). */
|
||||||
public static function flush_cache(): void {
|
public static function flush_cache(): void {
|
||||||
self::$table_ok = null;
|
self::$table_ok = null;
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<?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'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -78,13 +78,6 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
|||||||
// reasoning token cap + completion headroom — lives in the builder.
|
// reasoning token cap + completion headroom — lives in the builder.
|
||||||
$body = $builder->build_body( $messages, $model, $options );
|
$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
|
// Whether the outgoing request has reasoning enabled — drives the
|
||||||
// empty-completion retry decision after parse.
|
// empty-completion retry decision after parse.
|
||||||
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
|
$reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true );
|
||||||
|
|||||||
@@ -138,8 +138,7 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
|
|||||||
* @param int $offset Pagination offset (0-based).
|
* @param int $offset Pagination offset (0-based).
|
||||||
* @param int $limit Page size.
|
* @param int $limit Page size.
|
||||||
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
||||||
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions
|
* @throws \RuntimeException On HTTP error or malformed response.
|
||||||
* include up to 200 chars of the response body for diagnosis).
|
|
||||||
*/
|
*/
|
||||||
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
||||||
$payload = array(
|
$payload = array(
|
||||||
@@ -172,10 +171,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
|
|||||||
|
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
if ( 200 !== $status ) {
|
if ( 200 !== $status ) {
|
||||||
$raw_body = (string) wp_remote_retrieve_body( $response );
|
|
||||||
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt )
|
sprintf( 'Runware modelSearch returned HTTP %d', $status )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||||
* Version: 0.20.0
|
* Version: 0.19.2
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,8 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.20.0' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.19.2' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Partial: per-stage edit + replay panel (M3, v0.20.0).
|
|
||||||
*
|
|
||||||
* Rendered only for editable stages (writer chat stages with a
|
|
||||||
* persisted input). Included by dossier-stage-section.php; inherits its
|
|
||||||
* scope: $stage_name, $s_role, $section_id, $rerun (panel data),
|
|
||||||
* $dossier (spend/eligibility/post_id).
|
|
||||||
*
|
|
||||||
* UX contract (CPO guardrails): the copy states explicitly that saving
|
|
||||||
* forks a NEW version (original preserved), that re-running marks
|
|
||||||
* downstream stages stale, that execution is QUEUED (never implied
|
|
||||||
* synchronous), and what the run has already spent against its ceiling.
|
|
||||||
*
|
|
||||||
* Security: message contents via esc_textarea(); all attributes
|
|
||||||
* esc_attr(); spend numbers via number_format + esc_html.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$panel_prefill = is_array( $rerun['prefill'] ?? null ) ? $rerun['prefill'] : array(
|
|
||||||
'model' => '',
|
|
||||||
'messages' => array(),
|
|
||||||
);
|
|
||||||
$panel_fork = is_array( $rerun['fork'] ?? null ) ? $rerun['fork'] : null;
|
|
||||||
$panel_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array(
|
|
||||||
'settled' => 0.0,
|
|
||||||
'reserved' => 0.0,
|
|
||||||
'ceiling' => 0.0,
|
|
||||||
'warn' => false,
|
|
||||||
);
|
|
||||||
$held_usd = (float) $panel_spend['settled'] + (float) $panel_spend['reserved'];
|
|
||||||
?>
|
|
||||||
<div class="prab-edit-panel" id="<?php echo $section_id; // Already escaped. ?>-edit" hidden
|
|
||||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
|
||||||
data-agent-role="<?php echo esc_attr( $s_role ); ?>">
|
|
||||||
|
|
||||||
<p class="prab-edit-copy">
|
|
||||||
<?php esc_html_e( 'You are editing a copy of this stage\'s input. Saving creates a new version — the original is preserved unchanged. Re-running this stage executes your latest saved version as a queued background job and marks all downstream stages stale; nothing downstream re-runs until you trigger it.', 'prautoblogger' ); ?>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="prab-edit-spend<?php echo ! empty( $panel_spend['warn'] ) ? ' prab-edit-spend--warn' : ''; ?>">
|
|
||||||
<?php if ( (float) $panel_spend['ceiling'] > 0 ) : ?>
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: 1: spent USD, 2: ceiling USD. */
|
|
||||||
esc_html__( 'Run spend so far: $%1$s of the $%2$s per-run ceiling. Re-runs are new spend on this run.', 'prautoblogger' ),
|
|
||||||
esc_html( number_format( $held_usd, 4 ) ),
|
|
||||||
esc_html( number_format( (float) $panel_spend['ceiling'], 2 ) )
|
|
||||||
);
|
|
||||||
if ( ! empty( $panel_spend['warn'] ) ) {
|
|
||||||
echo ' ';
|
|
||||||
esc_html_e( 'Warning: this run is approaching its ceiling — the re-run may halt. Raise the per-run ceiling setting first if needed.', 'prautoblogger' );
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
<?php else : ?>
|
|
||||||
<?php esc_html_e( 'No per-run cost ceiling is configured for this run (monthly budget still applies).', 'prautoblogger' ); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="prab-edit-model">
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: %s: model identifier. */
|
|
||||||
esc_html__( 'Model (fixed for replay): %s', 'prautoblogger' ),
|
|
||||||
esc_html( (string) $panel_prefill['model'] )
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<?php foreach ( $panel_prefill['messages'] as $i => $panel_message ) : ?>
|
|
||||||
<div class="prab-edit-message">
|
|
||||||
<label class="prab-edit-role" for="<?php echo $section_id; // Already escaped. ?>-msg-<?php echo (int) $i; ?>">
|
|
||||||
<?php echo esc_html( ucfirst( (string) ( $panel_message['role'] ?? '' ) ) ); ?>
|
|
||||||
</label>
|
|
||||||
<textarea class="prab-edit-textarea large-text"
|
|
||||||
id="<?php echo $section_id; // Already escaped. ?>-msg-<?php echo (int) $i; ?>"
|
|
||||||
rows="<?php echo 'system' === ( $panel_message['role'] ?? '' ) ? 4 : 10; ?>"
|
|
||||||
data-role="<?php echo esc_attr( (string) ( $panel_message['role'] ?? '' ) ); ?>"><?php echo esc_textarea( (string) ( $panel_message['content'] ?? '' ) ); ?></textarea>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<div class="prab-edit-actions">
|
|
||||||
<button type="button" class="button button-primary prab-edit-save">
|
|
||||||
<?php esc_html_e( 'Save as new version', 'prautoblogger' ); ?>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="button prab-edit-rerun" <?php disabled( null === $panel_fork ); ?>>
|
|
||||||
<?php esc_html_e( 'Re-run this stage (queued)', 'prautoblogger' ); ?>
|
|
||||||
</button>
|
|
||||||
<span class="prab-edit-forkinfo" data-forkinfo>
|
|
||||||
<?php
|
|
||||||
if ( null !== $panel_fork ) {
|
|
||||||
printf(
|
|
||||||
/* translators: 1: version number, 2: author login, 3: datetime. */
|
|
||||||
esc_html__( 'Latest saved version: v%1$d by %2$s at %3$s', 'prautoblogger' ),
|
|
||||||
(int) $panel_fork['version'],
|
|
||||||
esc_html( (string) $panel_fork['author'] ),
|
|
||||||
esc_html( (string) $panel_fork['created_at'] )
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
esc_html_e( 'No edited version saved yet.', 'prautoblogger' );
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
<span class="prab-edit-feedback" aria-live="polite" data-feedback></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Partial: log-only stage section (M3/F3, v0.20.0).
|
|
||||||
*
|
|
||||||
* Renders stages that exist ONLY in generation_log — image_a/image_b,
|
|
||||||
* image_prompt_rewrite, llm_research, opik_eval_judge, ... — which the
|
|
||||||
* M2 dossier silently dropped because the image pipeline (and the
|
|
||||||
* research providers) never write run_stages rows (QA M2 F3). These
|
|
||||||
* sections show the call record (model, tokens, cost, status, raw
|
|
||||||
* trace) from their REAL data source; image stages additionally render
|
|
||||||
* the post's actual pipeline attachments ($dossier['images']).
|
|
||||||
*
|
|
||||||
* Variables injected by dossier-page.php:
|
|
||||||
* $stage_name (string) -- stage key
|
|
||||||
* $log_rows (array) -- generation_log rows for this stage
|
|
||||||
* $dossier (array) -- page view model (images)
|
|
||||||
*
|
|
||||||
* Security: identical escaping discipline to dossier-stage-section.php.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lo_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
|
|
||||||
$lo_is_image = in_array( $stage_name, array( 'image_a', 'image_b' ), true );
|
|
||||||
$lo_images = $lo_is_image && ! empty( $dossier['images'] ) ? (array) $dossier['images'] : array();
|
|
||||||
$lo_cost = 0.0;
|
|
||||||
$lo_status = '';
|
|
||||||
$lo_model = '—';
|
|
||||||
foreach ( $log_rows as $lo_row ) {
|
|
||||||
$lo_cost += (float) ( $lo_row['estimated_cost'] ?? 0 );
|
|
||||||
$lo_status = (string) ( $lo_row['response_status'] ?? '' );
|
|
||||||
if ( ! empty( $lo_row['model'] ) ) {
|
|
||||||
$lo_model = (string) $lo_row['model'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$lo_section_id = 'prab-logstage-' . esc_attr( $stage_name );
|
|
||||||
?>
|
|
||||||
<div class="prab-dossier-section prab-dossier-section--stage prab-dossier-section--log-only"
|
|
||||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
|
||||||
id="<?php echo $lo_section_id; // Already escaped. ?>">
|
|
||||||
|
|
||||||
<div class="prab-stage-header">
|
|
||||||
<h2 class="prab-section-title"><?php echo esc_html( $lo_label ); ?></h2>
|
|
||||||
<div class="prab-stage-header-meta">
|
|
||||||
<?php if ( '' !== $lo_status ) : ?>
|
|
||||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( $lo_status ); ?>">
|
|
||||||
<?php echo esc_html( $lo_status ); ?>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ( $lo_cost > 0 ) : ?>
|
|
||||||
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $lo_cost, 5 ) ); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="prab-stage-model"><?php echo esc_html( $lo_model ); ?></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ( ! empty( $lo_images ) ) : ?>
|
|
||||||
<div class="prab-image-grid">
|
|
||||||
<?php foreach ( $lo_images as $lo_image ) : ?>
|
|
||||||
<figure class="prab-image-card<?php echo ! empty( $lo_image['is_featured'] ) ? ' prab-image-card--chosen' : ''; ?>">
|
|
||||||
<img src="<?php echo esc_url( (string) $lo_image['url'] ); ?>" alt="<?php echo esc_attr( (string) $lo_image['role'] ); ?>" loading="lazy" />
|
|
||||||
<figcaption>
|
|
||||||
<?php echo esc_html( ucfirst( (string) $lo_image['role'] ) ); ?>
|
|
||||||
<?php if ( ! empty( $lo_image['is_featured'] ) ) : ?>
|
|
||||||
<span class="prab-chip prab-chip--chosen"><?php esc_html_e( 'Chosen (featured)', 'prautoblogger' ); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php elseif ( $lo_is_image ) : ?>
|
|
||||||
<p class="prab-output-absent"><?php esc_html_e( 'No pipeline attachments recorded for this article (images attach on publish).', 'prautoblogger' ); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="prab-stage-trace">
|
|
||||||
<button type="button" class="prab-trace-toggle button-link"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-controls="<?php echo $lo_section_id; // Already escaped. ?>-trace">
|
|
||||||
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
|
|
||||||
</button>
|
|
||||||
<div id="<?php echo $lo_section_id; // Already escaped. ?>-trace" class="prab-trace-detail" hidden>
|
|
||||||
<?php foreach ( $log_rows as $log_row ) : ?>
|
|
||||||
<div class="prab-trace-entry">
|
|
||||||
<div class="prab-trace-meta">
|
|
||||||
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
|
|
||||||
<span>
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: 1: prompt tokens, 2: completion tokens. */
|
|
||||||
esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ),
|
|
||||||
(int) ( $log_row['prompt_tokens'] ?? 0 ),
|
|
||||||
(int) ( $log_row['completion_tokens'] ?? 0 )
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
|
|
||||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
|
|
||||||
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
|
|
||||||
<details class="prab-trace-request">
|
|
||||||
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
|
|
||||||
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
|
|
||||||
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
|
|
||||||
</details>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
|
|
||||||
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- .prab-dossier-section--log-only -->
|
|
||||||
@@ -101,13 +101,6 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' );
|
|||||||
<span class="prab-dossier-status prab-dossier-status--<?php echo esc_attr( $dossier['post_status'] ?? '' ); ?>">
|
<span class="prab-dossier-status prab-dossier-status--<?php echo esc_attr( $dossier['post_status'] ?? '' ); ?>">
|
||||||
<?php echo esc_html( ucfirst( $dossier['post_status'] ?? '' ) ); ?>
|
<?php echo esc_html( ucfirst( $dossier['post_status'] ?? '' ) ); ?>
|
||||||
</span>
|
</span>
|
||||||
<?php // M3: run-summary audit badges -- visible, never tooltip-buried (CPO guardrail 2). ?>
|
|
||||||
<span class="prab-chip prab-chip--human prab-chip--header" data-header-chip="human" <?php echo ! empty( $dossier['human_modified_any'] ) ? '' : 'hidden'; ?>>
|
|
||||||
<?php esc_html_e( 'Human-modified', 'prautoblogger' ); ?>
|
|
||||||
</span>
|
|
||||||
<span class="prab-chip prab-chip--stale prab-chip--header" data-header-chip="stale" <?php echo ! empty( $dossier['stale_any'] ) ? '' : 'hidden'; ?>>
|
|
||||||
<?php esc_html_e( 'Has stale stages', 'prautoblogger' ); ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php if ( $post_id > 0 ) : ?>
|
<?php if ( $post_id > 0 ) : ?>
|
||||||
@@ -149,16 +142,6 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' );
|
|||||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// M3/F3: stages that exist only in generation_log (images,
|
|
||||||
// llm_research, ...) render from their real data source.
|
|
||||||
foreach ( ( $dossier['log_only_stages'] ?? array() ) as $stage_name ) {
|
|
||||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
|
||||||
if ( empty( $log_rows ) ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-log-stage-section.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( empty( $stages ) ) :
|
if ( empty( $stages ) ) :
|
||||||
?>
|
?>
|
||||||
<div class="prab-dossier-no-stages">
|
<div class="prab-dossier-no-stages">
|
||||||
@@ -207,8 +190,6 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' );
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-sidebar-cards.php'; ?>
|
|
||||||
|
|
||||||
<div class="prab-sidebar-card prab-cost-receipt">
|
<div class="prab-sidebar-card prab-cost-receipt">
|
||||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Cost Receipt', 'prautoblogger' ); ?></h3>
|
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Cost Receipt', 'prautoblogger' ); ?></h3>
|
||||||
<table class="prab-receipt-table">
|
<table class="prab-receipt-table">
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Partial: M3 sidebar cards — run spend (guardrail 4 visibility) and
|
|
||||||
* the per-stage model/prompt-version consolidation (QA M2 F2).
|
|
||||||
*
|
|
||||||
* Included by dossier-page.php; inherits its scope: $dossier, $run,
|
|
||||||
* $log_index, $stages.
|
|
||||||
*
|
|
||||||
* Security: all db-sourced values esc_html(); numbers via number_format.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if ( ! defined( 'ABSPATH' ) ) {
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sc_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array(
|
|
||||||
'settled' => 0.0,
|
|
||||||
'reserved' => 0.0,
|
|
||||||
'ceiling' => 0.0,
|
|
||||||
'warn' => false,
|
|
||||||
);
|
|
||||||
$sc_held = (float) $sc_spend['settled'] + (float) $sc_spend['reserved'];
|
|
||||||
?>
|
|
||||||
<div class="prab-sidebar-card prab-run-spend<?php echo ! empty( $sc_spend['warn'] ) ? ' prab-run-spend--warn' : ''; ?>">
|
|
||||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Spend', 'prautoblogger' ); ?></h3>
|
|
||||||
<dl class="prab-sidebar-dl">
|
|
||||||
<dt><?php esc_html_e( 'Settled', 'prautoblogger' ); ?></dt>
|
|
||||||
<dd>$<?php echo esc_html( number_format( (float) $sc_spend['settled'], 4 ) ); ?></dd>
|
|
||||||
<?php if ( (float) $sc_spend['reserved'] > 0 ) : ?>
|
|
||||||
<dt><?php esc_html_e( 'Reserved', 'prautoblogger' ); ?></dt>
|
|
||||||
<dd>$<?php echo esc_html( number_format( (float) $sc_spend['reserved'], 4 ) ); ?></dd>
|
|
||||||
<?php endif; ?>
|
|
||||||
<dt><?php esc_html_e( 'Ceiling', 'prautoblogger' ); ?></dt>
|
|
||||||
<dd>
|
|
||||||
<?php if ( (float) $sc_spend['ceiling'] > 0 ) : ?>
|
|
||||||
$<?php echo esc_html( number_format( (float) $sc_spend['ceiling'], 2 ) ); ?>
|
|
||||||
<?php else : ?>
|
|
||||||
<?php esc_html_e( 'none configured', 'prautoblogger' ); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<?php if ( ! empty( $sc_spend['warn'] ) ) : ?>
|
|
||||||
<p class="prab-spend-warning">
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: 1: spent USD, 2: ceiling USD. */
|
|
||||||
esc_html__( 'This run has committed $%1$s of its $%2$s ceiling — further re-runs may halt.', 'prautoblogger' ),
|
|
||||||
esc_html( number_format( $sc_held, 4 ) ),
|
|
||||||
esc_html( number_format( (float) $sc_spend['ceiling'], 2 ) )
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prab-sidebar-card prab-models-card">
|
|
||||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Models & Prompts', 'prautoblogger' ); ?></h3>
|
|
||||||
<table class="prab-models-table">
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ( $log_index as $sc_stage => $sc_rows ) : ?>
|
|
||||||
<?php
|
|
||||||
$sc_first = $sc_rows[0] ?? array();
|
|
||||||
$sc_model = ! empty( $sc_first['model'] ) ? (string) $sc_first['model'] : '—';
|
|
||||||
$sc_pv = ! empty( $sc_first['prompt_version'] ) ? (string) $sc_first['prompt_version'] : '—';
|
|
||||||
$sc_role = ! empty( $sc_first['agent_role'] ) ? (string) $sc_first['agent_role'] : '—';
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( (string) $sc_stage ) ); ?></th>
|
|
||||||
<td>
|
|
||||||
<span class="prab-models-model"><?php echo esc_html( $sc_model ); ?></span>
|
|
||||||
<span class="prab-models-meta">
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: 1: prompt version, 2: agent role. */
|
|
||||||
esc_html__( 'pv %1$s · %2$s', 'prautoblogger' ),
|
|
||||||
esc_html( $sc_pv ),
|
|
||||||
esc_html( $sc_role )
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@@ -3,10 +3,9 @@
|
|||||||
* Partial: single stage section for the Article Dossier.
|
* Partial: single stage section for the Article Dossier.
|
||||||
*
|
*
|
||||||
* Variables injected by dossier-page.php:
|
* Variables injected by dossier-page.php:
|
||||||
* $stage_row (array) -- run_stages row incl. display_output + ['rerun'] panel data (M3)
|
* $stage_row (array) -- run_stages row incl. display_output
|
||||||
* $stage_name (string) -- stage key
|
* $stage_name (string) -- stage key
|
||||||
* $log_rows (array) -- generation_log rows for this stage
|
* $log_rows (array) -- generation_log rows for this stage
|
||||||
* $dossier (array) -- page view model (spend/eligibility/post_id)
|
|
||||||
*
|
*
|
||||||
* Security: all model output escaped via esc_html() or wp_kses_post().
|
* Security: all model output escaped via esc_html() or wp_kses_post().
|
||||||
* request_json escaped via esc_html() -- treat as untrusted HTML.
|
* request_json escaped via esc_html() -- treat as untrusted HTML.
|
||||||
@@ -31,14 +30,9 @@ $s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $fi
|
|||||||
$s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—';
|
$s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—';
|
||||||
$has_raw_trace = ! empty( $log_rows );
|
$has_raw_trace = ! empty( $log_rows );
|
||||||
$section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role );
|
$section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role );
|
||||||
$rerun = is_array( $stage_row['rerun'] ?? null ) ? $stage_row['rerun'] : array();
|
|
||||||
$is_stale = ! empty( $rerun['stale'] );
|
|
||||||
$is_human = ! empty( $rerun['human_modified'] );
|
|
||||||
$attempts = (int) ( $rerun['attempt'] ?? 1 );
|
|
||||||
?>
|
?>
|
||||||
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?><?php echo $is_stale ? ' prab-stage--stale' : ''; ?>"
|
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?>"
|
||||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
||||||
data-agent-role="<?php echo esc_attr( $s_role ); ?>"
|
|
||||||
id="<?php echo $section_id; // Already escaped. ?>">
|
id="<?php echo $section_id; // Already escaped. ?>">
|
||||||
|
|
||||||
<div class="prab-stage-header">
|
<div class="prab-stage-header">
|
||||||
@@ -52,24 +46,6 @@ $attempts = (int) ( $rerun['attempt'] ?? 1 );
|
|||||||
<span class="prab-stage-status-pill prab-stage-status-pill--<?php echo esc_attr( $s_status ); ?>">
|
<span class="prab-stage-status-pill prab-stage-status-pill--<?php echo esc_attr( $s_status ); ?>">
|
||||||
<?php echo esc_html( ucfirst( $s_status ) ); ?>
|
<?php echo esc_html( ucfirst( $s_status ) ); ?>
|
||||||
</span>
|
</span>
|
||||||
<?php // M3 audit chips -- visible, never tooltip-buried (CPO guardrails 2+3). ?>
|
|
||||||
<span class="prab-chip prab-chip--stale" data-chip="stale" <?php echo $is_stale ? '' : 'hidden'; ?>>
|
|
||||||
<?php esc_html_e( 'Stale — upstream changed', 'prautoblogger' ); ?>
|
|
||||||
</span>
|
|
||||||
<span class="prab-chip prab-chip--human" data-chip="human" <?php echo $is_human ? '' : 'hidden'; ?>>
|
|
||||||
<?php esc_html_e( 'Human-edited input', 'prautoblogger' ); ?>
|
|
||||||
</span>
|
|
||||||
<?php if ( $attempts > 1 ) : ?>
|
|
||||||
<span class="prab-chip prab-chip--attempt">
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: %d: attempt count. */
|
|
||||||
esc_html__( 'Attempt %d', 'prautoblogger' ),
|
|
||||||
(int) $attempts
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ( $s_cost > 0 ) : ?>
|
<?php if ( $s_cost > 0 ) : ?>
|
||||||
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $s_cost, 5 ) ); ?></span>
|
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $s_cost, 5 ) ); ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -147,27 +123,4 @@ $attempts = (int) ( $rerun['attempt'] ?? 1 );
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php // ── M3: edit + re-run affordances (chained-cron; nothing is synchronous) ── ?>
|
|
||||||
<div class="prab-stage-rerun-footer">
|
|
||||||
<?php if ( ! empty( $rerun['editable'] ) ) : ?>
|
|
||||||
<button type="button" class="button prab-edit-toggle"
|
|
||||||
aria-expanded="false" aria-controls="<?php echo $section_id; // Already escaped. ?>-edit">
|
|
||||||
<?php esc_html_e( 'Edit input', 'prautoblogger' ); ?>
|
|
||||||
</button>
|
|
||||||
<?php elseif ( ! empty( $rerun['edit_reason'] ) ) : ?>
|
|
||||||
<span class="prab-edit-unavailable"><?php echo esc_html( (string) $rerun['edit_reason'] ); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ( ! empty( $rerun['rerun_from'] ) ) : ?>
|
|
||||||
<button type="button" class="button prab-rerun-from<?php echo $is_stale ? ' prab-rerun-from--stale' : ''; ?>"
|
|
||||||
data-stage="<?php echo esc_attr( $stage_name ); ?>">
|
|
||||||
<?php esc_html_e( 'Re-run from here', 'prautoblogger' ); ?>
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
if ( ! empty( $rerun['editable'] ) ) {
|
|
||||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-edit-panel.php';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
</div><!-- .prab-dossier-section--stage -->
|
</div><!-- .prab-dossier-section--stage -->
|
||||||
|
|||||||
@@ -103,18 +103,6 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
|
||||||
// v0.20.0: stale-stage warning chips (one batched query; CPO
|
|
||||||
// product AC -- the stale flag is visible at publish time).
|
|
||||||
$rq_post_runs = array();
|
|
||||||
foreach ( $query->posts as $rq_post ) {
|
|
||||||
$rq_run_id = (string) get_post_meta( $rq_post->ID, '_prautoblogger_run_id', true );
|
|
||||||
if ( '' !== $rq_run_id ) {
|
|
||||||
$rq_post_runs[ $rq_post->ID ] = $rq_run_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$rq_stale_runs = array_flip( PRAutoBlogger_Run_Stage_State::runs_with_stale_stages( array_values( $rq_post_runs ) ) );
|
|
||||||
?>
|
|
||||||
<table class="widefat striped prautoblogger-queue-table">
|
<table class="widefat striped prautoblogger-queue-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -149,11 +137,6 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<strong><?php the_title(); ?></strong>
|
<strong><?php the_title(); ?></strong>
|
||||||
<?php if ( isset( $rq_post_runs[ $post_id ], $rq_stale_runs[ $rq_post_runs[ $post_id ] ] ) ) : ?>
|
|
||||||
<a class="prab-review-stale-chip" href="<?php echo esc_url( PRAutoBlogger_Dossier_Page::url_for_post( (int) $post_id ) ); ?>">
|
|
||||||
<?php esc_html_e( 'Stale stages -- review dossier', 'prautoblogger' ); ?>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ( $editor_notes ) : ?>
|
<?php if ( $editor_notes ) : ?>
|
||||||
<div class="prautoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
|
<div class="prautoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -205,16 +205,3 @@ if ( ! class_exists( 'WP_Post' ) ) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin URL and version constants used by admin enqueue methods.
|
|
||||||
// Defined here so all tests that load admin classes can stub WP enqueue calls
|
|
||||||
// without hitting "Undefined constant" errors.
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_VERSION' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.3-test' );
|
|
||||||
}
|
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_DIR' ) ) {
|
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for the v0.20.0 board-card human_modified flag (CPO product AC:
|
|
||||||
* human-modified runs are visually distinct at the run-list level).
|
|
||||||
*
|
|
||||||
* Locks: ONE batched query flags all cards (no N+1), unflagged runs and
|
|
||||||
* missing-table sites degrade to false.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class BoardHumanModifiedFlagTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
|
||||||
*/
|
|
||||||
private $wpdb;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of get_col calls (N+1 guard).
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
private int $col_queries = 0;
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->col_queries = 0;
|
|
||||||
$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 ) ) . ' */';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'get_post_meta' )->alias(
|
|
||||||
static function ( $post_id, $key ) {
|
|
||||||
if ( '_prautoblogger_run_id' === $key ) {
|
|
||||||
return 'run-' . $post_id;
|
|
||||||
}
|
|
||||||
if ( '_prautoblogger_total_cost' === $key ) {
|
|
||||||
return '0.05';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' );
|
|
||||||
Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p );
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\WP_Query::$_test_posts_queue = array();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a WP_Post for the queue. */
|
|
||||||
private function make_post( int $id ): \WP_Post {
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = $id;
|
|
||||||
$post->post_title = 'Post ' . $id;
|
|
||||||
$post->post_date_gmt = '2026-06-12 00:00:00';
|
|
||||||
return $post;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cards whose runs carry human_modified rows get the flag; others
|
|
||||||
* stay false; exactly ONE batched query runs for the whole column.
|
|
||||||
*/
|
|
||||||
public function test_cards_flagged_in_one_batched_query(): void {
|
|
||||||
\WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ), $this->make_post( 2 ), $this->make_post( 3 ) );
|
|
||||||
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
|
||||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
++$this->col_queries;
|
|
||||||
$this->assertStringContainsString( 'human_modified = 1', (string) $sql );
|
|
||||||
return array( 'run-2' ); // Only post 2's run was edited.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards();
|
|
||||||
|
|
||||||
$this->assertCount( 3, $cards );
|
|
||||||
$this->assertFalse( $cards[0]['human_modified'] );
|
|
||||||
$this->assertTrue( $cards[1]['human_modified'] );
|
|
||||||
$this->assertFalse( $cards[2]['human_modified'] );
|
|
||||||
$this->assertSame( 1, $this->col_queries, 'one batched query, never per-card' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Missing substrate table: flags default false, zero flag queries.
|
|
||||||
*/
|
|
||||||
public function test_missing_table_degrades_to_false(): void {
|
|
||||||
\WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ) );
|
|
||||||
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturn( null );
|
|
||||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
|
||||||
function () {
|
|
||||||
++$this->col_queries;
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards();
|
|
||||||
|
|
||||||
$this->assertFalse( $cards[0]['human_modified'] );
|
|
||||||
$this->assertSame( 0, $this->col_queries );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Dossier_Actions: rerun + status endpoint tests (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Locks the chained-cron contract (queue only — ZERO state mutations in
|
|
||||||
* the AJAX layer) and the read-only stage-status poll shape.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class DossierActionsRerunTest extends DossierActionsTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chained-cron contract: rerun_stage only schedules — no run/stage
|
|
||||||
* state is mutated by the AJAX layer, and the response says queued.
|
|
||||||
*/
|
|
||||||
public function test_rerun_stage_queues_without_any_state_mutation(): void {
|
|
||||||
$this->wire_eligible_context();
|
|
||||||
// A saved fork exists for the replay check.
|
|
||||||
$wpdb_get_row_fork = array(
|
|
||||||
'version' => '1',
|
|
||||||
'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}',
|
|
||||||
);
|
|
||||||
// Rebuild get_row: forks resolve, run row terminal.
|
|
||||||
$_POST = array(
|
|
||||||
'post_id' => '99',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'agent_role' => 'writer',
|
|
||||||
);
|
|
||||||
// Replace the get_row wiring (fork now exists).
|
|
||||||
$wpdb = $this->create_mock_wpdb();
|
|
||||||
$wpdb->options = 'wp_options';
|
|
||||||
$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 ) ) . ' */';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) {
|
|
||||||
return ( false !== strpos( (string) $sql, 'stage_inputs' ) ) ? 'wp_prautoblogger_stage_inputs' : 'wp_prautoblogger_runs';
|
|
||||||
}
|
|
||||||
return null; // Lock free.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$wpdb->method( 'get_row' )->willReturnCallback(
|
|
||||||
static function ( $sql ) use ( $wpdb_get_row_fork ) {
|
|
||||||
if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) {
|
|
||||||
return $wpdb_get_row_fork;
|
|
||||||
}
|
|
||||||
return array(
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'status' => 'done',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$wpdb->method( 'query' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
$this->queries[] = (string) $sql;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$GLOBALS['wpdb'] = $wpdb;
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
|
|
||||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
|
||||||
$this->dispatch( array( $actions, 'on_rerun_stage' ) );
|
|
||||||
|
|
||||||
$this->assertTrue( $this->json['ok'] );
|
|
||||||
$this->assertTrue( $this->json['data']['queued'] );
|
|
||||||
$this->assertCount( 1, $this->scheduled );
|
|
||||||
$this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $this->scheduled[0]['hook'] );
|
|
||||||
$this->assertSame( array( 'run-1', 99, 'draft', 'writer', 'idea:abc123' ), array_slice( $this->scheduled[0]['args'], 0, 5 ) );
|
|
||||||
foreach ( $this->queries as $sql ) {
|
|
||||||
$this->assertStringNotContainsString( 'UPDATE', $sql, 'the AJAX layer must not mutate state' );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* stage_status: read-only poll returns run status + per-stage state
|
|
||||||
* with stale/human_modified flags.
|
|
||||||
*/
|
|
||||||
public function test_stage_status_returns_stage_states(): void {
|
|
||||||
$this->wire_eligible_context();
|
|
||||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
|
||||||
$_POST = array( 'post_id' => '99' );
|
|
||||||
|
|
||||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
|
||||||
$this->dispatch( array( $actions, 'on_stage_status' ) );
|
|
||||||
|
|
||||||
$this->assertTrue( $this->json['ok'] );
|
|
||||||
$this->assertArrayHasKey( 'run_status', $this->json['data'] );
|
|
||||||
$this->assertArrayHasKey( 'stages', $this->json['data'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Dossier_Actions: save_input endpoint tests (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Locks: published-post rejection (guardrail 5 server-side), fork
|
|
||||||
* structure validation (count/roles locked, contents non-empty), and
|
|
||||||
* the merged-fork save (model/options from the base, never the client).
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class DossierActionsSaveInputTest extends DossierActionsTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frozen post: save_input rejects server-side with 400 and inserts
|
|
||||||
* nothing (guardrail 5 is not just hidden UI).
|
|
||||||
*/
|
|
||||||
public function test_save_input_rejects_published_post(): void {
|
|
||||||
$this->wire_eligible_context( 'publish' );
|
|
||||||
$_POST = array(
|
|
||||||
'post_id' => '99',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'agent_role' => 'writer',
|
|
||||||
'messages' => '[]',
|
|
||||||
);
|
|
||||||
|
|
||||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
|
||||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
|
||||||
|
|
||||||
$this->assertFalse( $this->json['ok'] );
|
|
||||||
$this->assertSame( 400, $this->json['status'] );
|
|
||||||
$this->assertCount( 0, $this->inserted );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structure lock: changing a message ROLE (or count, or emptying a
|
|
||||||
* content) is rejected — forks are always replayable chat bodies.
|
|
||||||
*/
|
|
||||||
public function test_save_input_rejects_structure_mismatch(): void {
|
|
||||||
$this->wire_eligible_context();
|
|
||||||
$_POST = array(
|
|
||||||
'post_id' => '99',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'agent_role' => 'writer',
|
|
||||||
// Role flipped user->assistant vs the base body.
|
|
||||||
'messages' => '[{"role":"system","content":"sys"},{"role":"assistant","content":"edited"}]',
|
|
||||||
);
|
|
||||||
|
|
||||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
|
||||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
|
||||||
|
|
||||||
$this->assertFalse( $this->json['ok'] );
|
|
||||||
$this->assertCount( 0, $this->inserted );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid edit: fork v1 saved with the edited content merged into the
|
|
||||||
* base body; model/options come from the base, not the client.
|
|
||||||
*/
|
|
||||||
public function test_save_input_saves_merged_fork(): void {
|
|
||||||
$this->wire_eligible_context();
|
|
||||||
$_POST = array(
|
|
||||||
'post_id' => '99',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'agent_role' => 'writer',
|
|
||||||
'messages' => '[{"role":"system","content":"sys"},{"role":"user","content":"EDITED PROMPT"}]',
|
|
||||||
);
|
|
||||||
|
|
||||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
|
||||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
|
||||||
|
|
||||||
$this->assertTrue( $this->json['ok'] );
|
|
||||||
$this->assertSame( 1, $this->json['data']['version'] );
|
|
||||||
$this->assertCount( 1, $this->inserted );
|
|
||||||
$row = $this->inserted[0];
|
|
||||||
$this->assertSame( 'human', $row['source'] );
|
|
||||||
$this->assertSame( 'rhys', $row['author'] );
|
|
||||||
$this->assertSame( 'idea:abc123', $row['item_key'] );
|
|
||||||
$body = json_decode( (string) $row['request_json'], true );
|
|
||||||
$this->assertSame( 'EDITED PROMPT', $body['messages'][1]['content'] );
|
|
||||||
$this->assertSame( 'm', $body['model'] ); // From the base, not the client.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Shared harness for the Dossier_Actions endpoint tests (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Provides: mock wpdb with SQL-pattern wiring, JSON-response capture
|
|
||||||
* (wp_send_json_* throw a sentinel), cron-schedule capture, and the
|
|
||||||
* eligible-context fixture (draft post, terminal run, free lock).
|
|
||||||
* Split across two test classes for the 300-line file cap (the M2
|
|
||||||
* split-precedent applies to test files too).
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
abstract class DossierActionsTestCase extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
|
||||||
*/
|
|
||||||
protected $wpdb;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captured JSON response (success/error + payload + status).
|
|
||||||
*
|
|
||||||
* @var array|null
|
|
||||||
*/
|
|
||||||
protected $json = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SQL captured from $wpdb->query().
|
|
||||||
*
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
protected array $queries = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rows captured from $wpdb->insert().
|
|
||||||
*
|
|
||||||
* @var array<int, array>
|
|
||||||
*/
|
|
||||||
protected array $inserted = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cron events captured from wp_schedule_single_event().
|
|
||||||
*
|
|
||||||
* @var array<int, array>
|
|
||||||
*/
|
|
||||||
protected array $scheduled = array();
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->json = null;
|
|
||||||
$this->queries = array();
|
|
||||||
$this->inserted = array();
|
|
||||||
$this->scheduled = array();
|
|
||||||
$_POST = array();
|
|
||||||
|
|
||||||
$this->wpdb = $this->create_mock_wpdb();
|
|
||||||
$this->wpdb->options = 'wp_options';
|
|
||||||
$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( 'query' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
$this->queries[] = (string) $sql;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
|
||||||
function ( $table, $row ) {
|
|
||||||
$this->inserted[] = (array) $row;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'check_ajax_referer' )->justReturn( true );
|
|
||||||
Functions\when( 'current_user_can' )->justReturn( true );
|
|
||||||
Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } );
|
|
||||||
Functions\when( 'sanitize_key' )->alias( static function ( $val ) { return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) ); } );
|
|
||||||
Functions\when( 'wp_unslash' )->returnArg();
|
|
||||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
|
||||||
function ( $ts, $hook, $args ) {
|
|
||||||
$this->scheduled[] = array(
|
|
||||||
'hook' => $hook,
|
|
||||||
'args' => $args,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'spawn_cron' )->justReturn( true );
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
|
||||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
|
||||||
Functions\when( 'wp_send_json_success' )->alias(
|
|
||||||
function ( $data = null ) {
|
|
||||||
$this->json = array(
|
|
||||||
'ok' => true,
|
|
||||||
'data' => $data,
|
|
||||||
);
|
|
||||||
throw new \RuntimeException( 'json_sent' );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'wp_send_json_error' )->alias(
|
|
||||||
function ( $data = null, $status = 0 ) {
|
|
||||||
$this->json = array(
|
|
||||||
'ok' => false,
|
|
||||||
'data' => $data,
|
|
||||||
'status' => $status,
|
|
||||||
);
|
|
||||||
throw new \RuntimeException( 'json_sent' );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$user = new \stdClass();
|
|
||||||
$user->user_login = 'rhys';
|
|
||||||
Functions\when( 'wp_get_current_user' )->justReturn( $user );
|
|
||||||
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
$_POST = array();
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run an endpoint, swallowing the wp_send_json sentinel. */
|
|
||||||
protected function dispatch( callable $endpoint ): void {
|
|
||||||
try {
|
|
||||||
$endpoint();
|
|
||||||
} catch ( \RuntimeException $e ) {
|
|
||||||
if ( 'json_sent' !== $e->getMessage() ) {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire post meta + a draft post + a terminal run + free lock. */
|
|
||||||
protected function wire_eligible_context( string $post_status = 'draft', string $run_status = 'done' ): void {
|
|
||||||
Functions\when( 'get_post_meta' )->alias(
|
|
||||||
static function ( $post_id, $key ) {
|
|
||||||
if ( '_prautoblogger_run_id' === $key ) {
|
|
||||||
return 'run-1';
|
|
||||||
}
|
|
||||||
if ( '_prautoblogger_idea_hash' === $key ) {
|
|
||||||
return 'abc123';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = 99;
|
|
||||||
$post->post_status = $post_status;
|
|
||||||
Functions\when( 'get_post' )->justReturn( $post );
|
|
||||||
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) use ( $run_status ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
|
||||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
|
||||||
return 'wp_prautoblogger_stage_inputs';
|
|
||||||
}
|
|
||||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
|
||||||
return 'wp_prautoblogger_run_stages';
|
|
||||||
}
|
|
||||||
return 'wp_prautoblogger_runs';
|
|
||||||
}
|
|
||||||
if ( false !== strpos( $sql, 'MAX(version)' ) ) {
|
|
||||||
return null; // First fork version.
|
|
||||||
}
|
|
||||||
return null; // Lock free; no seed.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
|
||||||
static function ( $sql ) use ( $run_status ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
|
||||||
return null; // No fork yet.
|
|
||||||
}
|
|
||||||
return array(
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'status' => $run_status,
|
|
||||||
'ceiling_usd' => '0.50',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// resolve_base_body falls through to the gen_log query.
|
|
||||||
$this->wpdb->method( 'get_results' )->willReturn(
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'id' => '11',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'request_json' => '{"model":"m","messages":[{"role":"system","content":"sys"},{"role":"user","content":"orig"}]}',
|
|
||||||
'response_status' => 'success',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for the v0.20.0 dossier view-model additions (M3 + F2/F3).
|
|
||||||
*
|
|
||||||
* Locks: item-scoped stage filtering (multi-article runs no longer
|
|
||||||
* collide), other-post gen_log row exclusion, per-stage rerun panel
|
|
||||||
* data (policy reasons on non-editable stages; editable writer stage
|
|
||||||
* with recorded input), log-only stage exposure (F3) and the spend
|
|
||||||
* strip shape (guardrail 4 visibility).
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class DossierAssemblerRerunDataTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
|
||||||
*/
|
|
||||||
private $wpdb;
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->wpdb = $this->create_mock_wpdb();
|
|
||||||
$this->wpdb->options = 'wp_options';
|
|
||||||
$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 ) ) . ' */';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 );
|
|
||||||
Functions\when( 'get_children' )->justReturn( array() );
|
|
||||||
Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' );
|
|
||||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
|
||||||
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the standard post + meta wiring. */
|
|
||||||
private function wire_post(): void {
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = 99;
|
|
||||||
$post->post_title = 'Test Article';
|
|
||||||
$post->post_status = 'draft';
|
|
||||||
Functions\when( 'get_post' )->justReturn( $post );
|
|
||||||
Functions\when( 'get_post_meta' )->alias(
|
|
||||||
static function ( $post_id, $key ) {
|
|
||||||
$map = array(
|
|
||||||
'_prautoblogger_run_id' => 'run-1',
|
|
||||||
'_prautoblogger_idea_hash' => 'abc123',
|
|
||||||
'_prautoblogger_editor_verdict' => 'approved',
|
|
||||||
'_prautoblogger_article_type' => 'guide',
|
|
||||||
);
|
|
||||||
return $map[ $key ] ?? '';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire wpdb for: tables exist, run row, stage rows, gen_log rows, lock free. */
|
|
||||||
private function wire_data( array $stage_rows, array $log_rows ): void {
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
|
||||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
|
||||||
return 'wp_prautoblogger_stage_inputs';
|
|
||||||
}
|
|
||||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
|
||||||
return 'wp_prautoblogger_run_stages';
|
|
||||||
}
|
|
||||||
return 'wp_prautoblogger_runs';
|
|
||||||
}
|
|
||||||
return null; // Lock free.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) {
|
|
||||||
return null; // No forks saved.
|
|
||||||
}
|
|
||||||
return array(
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'status' => 'done',
|
|
||||||
'ceiling_usd' => '0.50',
|
|
||||||
'settled_usd' => '0.30',
|
|
||||||
'reserved_usd' => '0.15',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
|
||||||
static function ( $sql ) use ( $stage_rows, $log_rows ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
|
||||||
return $stage_rows;
|
|
||||||
}
|
|
||||||
if ( false !== strpos( $sql, 'generation_log' ) ) {
|
|
||||||
return $log_rows;
|
|
||||||
}
|
|
||||||
return array(); // run_decisions.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stage query is item-scoped (idea hash -> item_key IN (item, ''))
|
|
||||||
* and gen_log rows belonging to OTHER posts are dropped while
|
|
||||||
* unlinked (post_id NULL/0) rows are kept.
|
|
||||||
*/
|
|
||||||
public function test_item_scoping_and_other_post_log_exclusion(): void {
|
|
||||||
$this->wire_post();
|
|
||||||
$captured_stage_sql = null;
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) {
|
|
||||||
return false !== strpos( (string) $sql, 'stage_inputs' ) ? 'wp_prautoblogger_stage_inputs'
|
|
||||||
: ( false !== strpos( (string) $sql, 'run_stages' ) ? 'wp_prautoblogger_run_stages' : 'wp_prautoblogger_runs' );
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => 'run-1', 'status' => 'done', 'ceiling_usd' => '0' ) );
|
|
||||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
|
||||||
static function ( $sql ) use ( &$captured_stage_sql ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
|
||||||
$captured_stage_sql = $sql;
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
if ( false !== strpos( $sql, 'generation_log' ) ) {
|
|
||||||
return array(
|
|
||||||
array( 'id' => '1', 'stage' => 'draft', 'post_id' => '99', 'model' => 'm', 'request_json' => null ),
|
|
||||||
array( 'id' => '2', 'stage' => 'draft', 'post_id' => '77', 'model' => 'm', 'request_json' => null ),
|
|
||||||
array( 'id' => '3', 'stage' => 'llm_research', 'post_id' => null, 'model' => 'm', 'request_json' => null ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 );
|
|
||||||
|
|
||||||
$this->assertSame( 'idea:abc123', $dossier['item_key'] );
|
|
||||||
$this->assertStringContainsString( "item_key IN (%s, '')", (string) $captured_stage_sql );
|
|
||||||
// Post 77's draft row dropped; this post's row + unlinked research row kept.
|
|
||||||
$this->assertCount( 1, $dossier['gen_log_index']['draft'] );
|
|
||||||
$this->assertSame( '1', $dossier['gen_log_index']['draft'][0]['id'] );
|
|
||||||
$this->assertCount( 1, $dossier['gen_log_index']['llm_research'] );
|
|
||||||
// F3: llm_research has no run_stages row -> exposed as log-only.
|
|
||||||
$this->assertContains( 'llm_research', $dossier['log_only_stages'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-stage rerun data: a writer stage with a recorded request is
|
|
||||||
* editable with prefill; review carries a policy reason instead; the
|
|
||||||
* spend strip reflects the ledger row (guardrail 4 visibility).
|
|
||||||
*/
|
|
||||||
public function test_stage_rerun_panel_data_and_spend(): void {
|
|
||||||
$this->wire_post();
|
|
||||||
$stage_rows = array(
|
|
||||||
array(
|
|
||||||
'id' => '1',
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'agent_role' => 'writer',
|
|
||||||
'item_key' => 'idea:abc123',
|
|
||||||
'status' => 'done',
|
|
||||||
'attempt' => '1',
|
|
||||||
'cost_usd' => '0.01',
|
|
||||||
'meta_json' => '{"output":"text"}',
|
|
||||||
'stale' => '0',
|
|
||||||
'human_modified' => '1',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => '2',
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'stage' => 'review',
|
|
||||||
'agent_role' => 'editor',
|
|
||||||
'item_key' => 'idea:abc123',
|
|
||||||
'status' => 'done',
|
|
||||||
'attempt' => '1',
|
|
||||||
'cost_usd' => '0.005',
|
|
||||||
'meta_json' => null,
|
|
||||||
'stale' => '1',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$log_rows = array(
|
|
||||||
array(
|
|
||||||
'id' => '10',
|
|
||||||
'stage' => 'draft',
|
|
||||||
'post_id' => '99',
|
|
||||||
'model' => 'google/gemini-2.5-flash-lite',
|
|
||||||
'request_json' => '{"model":"google/gemini-2.5-flash-lite","messages":[{"role":"user","content":"draft prompt"}]}',
|
|
||||||
'response_status' => 'success',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$this->wire_data( $stage_rows, $log_rows );
|
|
||||||
|
|
||||||
$dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 );
|
|
||||||
|
|
||||||
$draft = $dossier['stages']['draft:writer']['rerun'];
|
|
||||||
$this->assertTrue( $draft['editable'] );
|
|
||||||
$this->assertSame( 'draft prompt', $draft['prefill']['messages'][0]['content'] );
|
|
||||||
$this->assertTrue( $draft['human_modified'] );
|
|
||||||
$this->assertTrue( $dossier['human_modified_any'] );
|
|
||||||
|
|
||||||
$review = $dossier['stages']['review:editor']['rerun'];
|
|
||||||
$this->assertFalse( $review['editable'] );
|
|
||||||
$this->assertStringContainsString( 'Re-run from here', $review['edit_reason'] );
|
|
||||||
$this->assertTrue( $review['stale'] );
|
|
||||||
$this->assertTrue( $review['rerun_from'] );
|
|
||||||
$this->assertTrue( $dossier['stale_any'] );
|
|
||||||
|
|
||||||
$this->assertSame( 0.30, $dossier['spend']['settled'] );
|
|
||||||
$this->assertSame( 0.15, $dossier['spend']['reserved'] );
|
|
||||||
$this->assertSame( 0.50, $dossier['spend']['ceiling'] );
|
|
||||||
$this->assertTrue( $dossier['spend']['warn'] ); // 0.45/0.50 = 90% >= 80%.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,14 +37,6 @@ class DossierDataAssemblerTest extends BaseTestCase {
|
|||||||
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
|
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
|
||||||
// Static method -- stubbed via wpdb table check returning the table name.
|
// Static method -- stubbed via wpdb table check returning the table name.
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
|
|
||||||
// v0.20.0 additions: image data + eligibility paths.
|
|
||||||
Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 );
|
|
||||||
Functions\when( 'get_children' )->justReturn( array() );
|
|
||||||
Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' );
|
|
||||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
|
||||||
|
|
||||||
// table_name returns wp_prautoblogger_run_stages.
|
// table_name returns wp_prautoblogger_run_stages.
|
||||||
$this->wpdb->prefix = 'wp_';
|
$this->wpdb->prefix = 'wp_';
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for Dossier admin page hookname faithfulness — request-time mechanism.
|
|
||||||
*
|
|
||||||
* ## Why the v0.19.2 hide-by-unset pattern caused a 403
|
|
||||||
*
|
|
||||||
* WordPress computes a page's hook suffix at TWO separate moments:
|
|
||||||
*
|
|
||||||
* 1. Registration time (inside add_submenu_page):
|
|
||||||
* WP resolves the parent slug against $admin_page_hooks. If found, the hookname
|
|
||||||
* becomes "{parent_key}_page_{slug}" (e.g. "prautoblogger_page_prautoblogger-dossier").
|
|
||||||
*
|
|
||||||
* 2. Request time (inside wp-admin/admin.php):
|
|
||||||
* WP calls get_admin_page_parent() which scans $submenu for the slug. If not found
|
|
||||||
* (because it was unset post-registration), it falls back to "admin_page_{slug}".
|
|
||||||
*
|
|
||||||
* When these two hooknames diverge, the render callback lives under name-A but WP
|
|
||||||
* dispatches to name-B -- no handler found -- wp_die(403).
|
|
||||||
*
|
|
||||||
* The fix: options.php-parent guarantees "admin_page_{slug}" at BOTH moments.
|
|
||||||
* No $submenu mutation is needed or permitted.
|
|
||||||
*
|
|
||||||
* ## Discriminator function: simulate_request_time_hookname
|
|
||||||
*
|
|
||||||
* Replicates get_admin_page_parent() + hookname derivation in wp-admin/admin.php.
|
|
||||||
* Returns different hooknames for hide-by-unset vs options.php-parent, making the
|
|
||||||
* 403 divergence mechanically verifiable in a unit test.
|
|
||||||
*
|
|
||||||
* Structural invariant tests (parent slug, URL shape, priority) live in
|
|
||||||
* DossierMenuRegistrationTest.
|
|
||||||
*
|
|
||||||
* @see DossierMenuRegistrationTest -- structural invariants + deep-link URLs + priority.
|
|
||||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
|
||||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
class DossierHooknameMechanismTest extends DossierMenuRegistrationTestCase {
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// § REQUEST-TIME HOOKNAME FAITHFULNESS
|
|
||||||
// The primary regression guard for the 403 incident class.
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulate the request-time hookname resolution algorithm.
|
|
||||||
*
|
|
||||||
* Replicates get_admin_page_parent() + the hookname derivation in
|
|
||||||
* wp-admin/admin.php::wp_admin_canonical_url() / do_action("admin_{$page}"):
|
|
||||||
*
|
|
||||||
* scan $submenu for the slug
|
|
||||||
* if found at parent P → return hookname derived from P
|
|
||||||
* if not found → return "admin_page_{slug}"
|
|
||||||
*
|
|
||||||
* This is the discriminator function: it WILL return a different hookname than
|
|
||||||
* add_submenu_page returned when the slug has been unset from $submenu.
|
|
||||||
*
|
|
||||||
* @param array<string, array<int, array<int, string>>> $submenu_state $submenu after all registration + mutations.
|
|
||||||
* @param string $slug Page slug to resolve.
|
|
||||||
* @return string Request-time hookname.
|
|
||||||
*/
|
|
||||||
private function simulate_request_time_hookname( array $submenu_state, string $slug ): string {
|
|
||||||
foreach ( $submenu_state as $parent => $items ) {
|
|
||||||
foreach ( $items as $item ) {
|
|
||||||
if ( isset( $item[2] ) && $slug === $item[2] ) {
|
|
||||||
// Found the slug; derive hookname the same way as registration.
|
|
||||||
if ( 'options.php' === $parent ) {
|
|
||||||
return 'admin_page_' . $slug;
|
|
||||||
}
|
|
||||||
$parent_key = str_replace( '.php', '', $parent );
|
|
||||||
return $parent_key . '_page_' . $slug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Slug absent from $submenu -- WP falls back to the orphan namespace.
|
|
||||||
return 'admin_page_' . $slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REQUEST-TIME FAITHFULNESS: registration hookname must equal request-time hookname.
|
|
||||||
*
|
|
||||||
* This test directly discriminates the two patterns:
|
|
||||||
*
|
|
||||||
* HIDE-BY-UNSET (v0.19.2, BROKEN):
|
|
||||||
* - add_submenu_page('prautoblogger-settings', ...) → 'prautoblogger_page_prautoblogger-dossier'
|
|
||||||
* - unset($submenu['prautoblogger-settings'][$idx])
|
|
||||||
* - post-registration $submenu does NOT contain the slug
|
|
||||||
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
|
|
||||||
* - 'prautoblogger_page_...' ≠ 'admin_page_...' → FAIL ✓ (test correctly detects broken state)
|
|
||||||
*
|
|
||||||
* OPTIONS.PHP-PARENT (v0.19.3, FIXED):
|
|
||||||
* - add_submenu_page('options.php', ...) → 'admin_page_prautoblogger-dossier'
|
|
||||||
* - no $submenu mutation
|
|
||||||
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
|
|
||||||
* - MATCH → PASS ✓
|
|
||||||
*
|
|
||||||
* This test ALSO fails if any future change reintroduces $submenu mutation,
|
|
||||||
* or switches back to a plugin-owned parent without preserving the submenu entry.
|
|
||||||
*/
|
|
||||||
public function test_request_time_hookname_matches_registration_hookname(): void {
|
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
|
||||||
$dossier_page->on_register_menu();
|
|
||||||
|
|
||||||
$this->assertNotNull(
|
|
||||||
$this->registration_hookname,
|
|
||||||
'add_submenu_page must be called during on_register_menu()'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Capture $submenu state AFTER all mutations that on_register_menu() performs.
|
|
||||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
|
|
||||||
global $submenu;
|
|
||||||
$submenu_after = $submenu;
|
|
||||||
|
|
||||||
$request_time_hookname = $this->simulate_request_time_hookname(
|
|
||||||
$submenu_after,
|
|
||||||
\PRAutoBlogger_Dossier_Page::PAGE_SLUG
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
$this->registration_hookname,
|
|
||||||
$request_time_hookname,
|
|
||||||
sprintf(
|
|
||||||
'Hookname mismatch: registration="%s", request-time="%s". '
|
|
||||||
. 'This is the 403 failure class. '
|
|
||||||
. 'The hide-by-unset pattern unsets the slug from $submenu after registration, '
|
|
||||||
. 'so get_admin_page_parent() fails at request time, WP recomputes under '
|
|
||||||
. 'the orphan "admin_page_*" namespace, finds no registered handler, '
|
|
||||||
. 'and calls wp_die(403). Fix: use options.php as parent -- hookname is '
|
|
||||||
. '"admin_page_{slug}" at both registration and request time, no mutation needed.',
|
|
||||||
$this->registration_hookname,
|
|
||||||
$request_time_hookname
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REGRESSION PROOF: documents that v0.19.2 hide-by-unset would have failed the
|
|
||||||
* request-time test above.
|
|
||||||
*
|
|
||||||
* Directly simulates the broken implementation, applies simulate_request_time_hookname,
|
|
||||||
* and asserts the MISMATCH -- proving the discriminator is effective.
|
|
||||||
*
|
|
||||||
* This test documents the failure state and serves as evidence that the test methodology
|
|
||||||
* is correct. It passes by verifying the broken pattern produces the wrong hookname.
|
|
||||||
*/
|
|
||||||
public function test_hide_by_unset_would_fail_request_time_check(): void {
|
|
||||||
// --- SIMULATE V0.19.2 hide-by-unset IMPLEMENTATION ---
|
|
||||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
|
|
||||||
global $submenu;
|
|
||||||
|
|
||||||
// Step 1: Registration (add_submenu_page with plugin parent).
|
|
||||||
// Real add_submenu_page stub returns 'prautoblogger_page_prautoblogger-dossier'
|
|
||||||
// when parent is 'prautoblogger-settings'. Populate $submenu as WP does.
|
|
||||||
$broken_parent_slug = 'prautoblogger-settings';
|
|
||||||
$slug = 'prautoblogger-dossier';
|
|
||||||
|
|
||||||
$submenu[ $broken_parent_slug ] = $submenu[ $broken_parent_slug ] ?? array();
|
|
||||||
$submenu[ $broken_parent_slug ][] = array( 'Article Dossier', 'manage_options', $slug, 'Dossier' );
|
|
||||||
|
|
||||||
// The hookname WP computed at registration time with plugin-owned parent:
|
|
||||||
$broken_registration_hookname = 'prautoblogger_page_' . $slug;
|
|
||||||
|
|
||||||
// Step 2: The unset mutation (the bug).
|
|
||||||
// This is what v0.19.2's on_register_menu() did after add_submenu_page.
|
|
||||||
foreach ( $submenu[ $broken_parent_slug ] as $idx => $item ) {
|
|
||||||
if ( isset( $item[2] ) && $slug === $item[2] ) {
|
|
||||||
unset( $submenu[ $broken_parent_slug ][ $idx ] );
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Request time -- slug is now ABSENT from $submenu.
|
|
||||||
$request_time_hookname = $this->simulate_request_time_hookname( $submenu, $slug );
|
|
||||||
|
|
||||||
// THE MISMATCH that caused the 403:
|
|
||||||
$this->assertNotSame(
|
|
||||||
$broken_registration_hookname,
|
|
||||||
$request_time_hookname,
|
|
||||||
'Test methodology error: hide-by-unset must produce a hookname mismatch. '
|
|
||||||
. 'If this assertion fails the discriminator is broken.'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Specifically: request-time falls back to admin_page_* orphan namespace.
|
|
||||||
$this->assertSame(
|
|
||||||
'admin_page_' . $slug,
|
|
||||||
$request_time_hookname,
|
|
||||||
'Request-time fallback must be admin_page_{slug} when slug absent from $submenu.'
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
'prautoblogger_page_' . $slug,
|
|
||||||
$broken_registration_hookname,
|
|
||||||
'Registration-time hookname with prautoblogger-settings parent must be prautoblogger_page_{slug}.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +1,76 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Tests for Dossier admin page menu registration — structural invariants.
|
* Tests for Article Dossier admin page menu registration.
|
||||||
*
|
*
|
||||||
* Covers: parent slug (options.php), $submenu non-mutation, page slug constant,
|
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
|
||||||
* asset enqueue gate prefix, deep-link URL shape, and hook priority ordering.
|
* any submenu registered BEFORE add_menu_page fires will resolve under the
|
||||||
|
* fallback hookname `admin_page_*` instead of `prautoblogger_page_*`, causing
|
||||||
|
* a wp_die 404 at request time.
|
||||||
*
|
*
|
||||||
* Request-time hookname faithfulness tests live in DossierHooknameMechanismTest.
|
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
|
||||||
*
|
* registration) at priority 12 -- after board (11) and parent (10).
|
||||||
* @see DossierHooknameMechanismTest -- request-time discriminator + regression proof.
|
|
||||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
|
||||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
|
||||||
*
|
*
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
use Brain\Monkey;
|
use Brain\Monkey;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
|
class DossierMenuRegistrationTest extends BaseTestCase {
|
||||||
|
|
||||||
// =========================================================================
|
/** @var array<int, string> Call sequence spy. */
|
||||||
// § STRUCTURAL INVARIANTS
|
private array $call_sequence = [];
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
protected function setUp(): void {
|
||||||
* Parent slug must be PARENT_SLUG constant ('options.php').
|
parent::setUp();
|
||||||
*
|
$this->call_sequence = [];
|
||||||
* FAILS on v0.19.2 (parent was 'prautoblogger-settings').
|
|
||||||
* PASSES after v0.19.3 fix (parent is 'options.php').
|
|
||||||
*/
|
|
||||||
public function test_dossier_parent_slug_is_options_php(): void {
|
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
|
||||||
$dossier_page->on_register_menu();
|
|
||||||
|
|
||||||
$this->assertSame(
|
Functions\when( 'add_menu_page' )->alias(
|
||||||
'options.php',
|
function () {
|
||||||
$this->captured_parent_slug,
|
$this->call_sequence[] = 'add_menu_page';
|
||||||
'Dossier must use options.php as parent (canonical hidden-page pattern). '
|
return 'prautoblogger-settings';
|
||||||
. 'Never use a plugin-owned parent with post-registration $submenu unset -- '
|
|
||||||
. 'that is the v0.19.2 403 bug class. See CONVENTIONS.md §Hidden Admin Pages.'
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
\PRAutoBlogger_Dossier_Page::PARENT_SLUG,
|
|
||||||
$this->captured_parent_slug,
|
|
||||||
'Captured parent slug must match PARENT_SLUG constant.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No $submenu mutation after registration.
|
|
||||||
*
|
|
||||||
* options.php-parent pages are hidden by construction; the slug must remain
|
|
||||||
* in $submenu so get_admin_page_parent() can resolve the parent at request time.
|
|
||||||
*
|
|
||||||
* FAILS on v0.19.2 (slug was unset from $submenu after registration).
|
|
||||||
* PASSES after v0.19.3 fix (no mutation).
|
|
||||||
*/
|
|
||||||
public function test_no_submenu_mutation_after_registration(): void {
|
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
|
||||||
$dossier_page->on_register_menu();
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
|
|
||||||
global $submenu;
|
|
||||||
|
|
||||||
// The dossier slug must still be present somewhere in $submenu.
|
|
||||||
$slug_found = false;
|
|
||||||
foreach ( $submenu as $parent => $items ) {
|
|
||||||
foreach ( $items as $item ) {
|
|
||||||
if ( isset( $item[2] ) && \PRAutoBlogger_Dossier_Page::PAGE_SLUG === $item[2] ) {
|
|
||||||
$slug_found = true;
|
|
||||||
break 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$slug_found,
|
|
||||||
'Dossier slug must remain in $submenu after on_register_menu() completes. '
|
|
||||||
. 'Unsetting it causes get_admin_page_parent() to fail at request time and '
|
|
||||||
. 'produces the admin_page_* orphan hookname -- the v0.19.2 403 bug class. '
|
|
||||||
. 'options.php-parent pages are hidden by construction; no mutation needed.'
|
|
||||||
);
|
);
|
||||||
}
|
Functions\when( 'add_submenu_page' )->alias(
|
||||||
|
function ( string $parent_slug, string $page_title, string $menu_title, string $capability, string $menu_slug ) {
|
||||||
/**
|
$this->call_sequence[] = 'add_submenu_page:' . $menu_slug;
|
||||||
* Page slug must be PAGE_SLUG constant ('prautoblogger-dossier').
|
return 'prautoblogger_page_' . $menu_slug;
|
||||||
*/
|
}
|
||||||
public function test_dossier_uses_correct_slug(): void {
|
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
|
||||||
$dossier_page->on_register_menu();
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
|
|
||||||
$this->captured_menu_slug,
|
|
||||||
'Dossier must register under PAGE_SLUG.'
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
Functions\when( 'current_user_can' )->justReturn( true );
|
||||||
* Asset enqueue gate must use 'admin_page_' prefix (not 'prautoblogger_page_').
|
Functions\when( 'get_option' )->justReturn( false );
|
||||||
*
|
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||||
* options.php-parent pages always resolve to 'admin_page_{slug}'.
|
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||||
* FAILS on v0.19.2 (gate checked 'prautoblogger_page_prautoblogger-dossier').
|
|
||||||
* PASSES after v0.19.3 fix (gate checks 'admin_page_prautoblogger-dossier').
|
|
||||||
*/
|
|
||||||
public function test_asset_enqueue_gate_uses_admin_page_prefix(): void {
|
|
||||||
Functions\when( 'wp_enqueue_style' )->justReturn( null );
|
|
||||||
Functions\when( 'wp_enqueue_script' )->justReturn( null );
|
|
||||||
|
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
|
||||||
|
|
||||||
$expected_hook = 'admin_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG;
|
|
||||||
|
|
||||||
// Wrong hook -- assets must NOT enqueue.
|
|
||||||
$enqueued = false;
|
|
||||||
Functions\when( 'wp_enqueue_style' )->alias( function () use ( &$enqueued ) {
|
|
||||||
$enqueued = true;
|
|
||||||
} );
|
} );
|
||||||
|
|
||||||
$dossier_page->on_enqueue_assets( 'wrong_hook_prautoblogger_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG );
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
||||||
$this->assertFalse(
|
$GLOBALS['submenu'] = array();
|
||||||
$enqueued,
|
|
||||||
'Assets must NOT enqueue when hook_suffix is the old prautoblogger_page_* hook.'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Correct hook -- assets must enqueue.
|
|
||||||
$dossier_page->on_enqueue_assets( $expected_hook );
|
|
||||||
$this->assertTrue(
|
|
||||||
$enqueued,
|
|
||||||
sprintf(
|
|
||||||
'Assets must enqueue when hook_suffix is "%s" (admin_page_* from options.php parent).',
|
|
||||||
$expected_hook
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
protected function tearDown(): void {
|
||||||
// § DEEP-LINK URL INVARIANCE
|
$this->call_sequence = [];
|
||||||
// =========================================================================
|
unset( $GLOBALS['submenu'] );
|
||||||
|
parent::tearDown();
|
||||||
/**
|
|
||||||
* url_for_post() must produce admin.php?page=prautoblogger-dossier&post_id=N.
|
|
||||||
*
|
|
||||||
* Deep-link URLs are parent-agnostic -- they reference only the page slug.
|
|
||||||
* The fix changes only the registration parent; the URL shape is unchanged.
|
|
||||||
*/
|
|
||||||
public function test_url_for_post_contains_slug_and_post_id(): void {
|
|
||||||
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
|
|
||||||
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
|
|
||||||
$url,
|
|
||||||
'url_for_post() must contain the dossier page slug.'
|
|
||||||
);
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
'42',
|
|
||||||
$url,
|
|
||||||
'url_for_post() must contain the post_id.'
|
|
||||||
);
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
'admin.php',
|
|
||||||
$url,
|
|
||||||
'url_for_post() must use admin.php (parent-agnostic deep-link).'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* url_for_post() URL shape is unchanged from v0.19.2.
|
* Dossier on_register_menu must be hooked at priority > board (11).
|
||||||
|
* Mirrors the bindings in register_admin_hooks():
|
||||||
|
* parent at 10, board at 11, dossier at 12.
|
||||||
*
|
*
|
||||||
* Confirms the exact URL format that board cards and the metabox link depend on.
|
* FAILS if dossier drops to priority ≤ 11 (same as or before board).
|
||||||
|
* FAILS if dossier drops to priority ≤ 10 (same as or before parent).
|
||||||
*/
|
*/
|
||||||
public function test_url_for_post_exact_shape(): void {
|
public function test_dossier_hook_registered_after_board(): void {
|
||||||
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 930 );
|
|
||||||
|
|
||||||
// Must contain admin.php?page=prautoblogger-dossier&post_id=930
|
|
||||||
// (or equivalent query string order -- admin_url may vary order).
|
|
||||||
$this->assertStringContainsString( 'page=prautoblogger-dossier', $url );
|
|
||||||
$this->assertStringContainsString( 'post_id=930', $url );
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// § HOOK REGISTRATION ORDERING (priority guard)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dossier on_register_menu must remain at priority 12 (after board 11, parent 10).
|
|
||||||
*
|
|
||||||
* The options.php-parent fix removes the dependency on ordering relative to the
|
|
||||||
* plugin's own parent menu (since we no longer add a submenu under it), but we
|
|
||||||
* keep the priority assertion to prevent accidental priority regression.
|
|
||||||
*/
|
|
||||||
public function test_dossier_hook_registered_at_priority_12(): void {
|
|
||||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||||
$board_page = new \PRAutoBlogger_Board_Page();
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
|
||||||
|
// Mirror class-prautoblogger.php register_admin_hooks() priorities.
|
||||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||||
@@ -221,15 +94,56 @@ class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
|
|||||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertGreaterThan(
|
$this->assertGreaterThan( $parent_priority, $board_priority,
|
||||||
$parent_priority,
|
'Board priority must exceed parent priority.' );
|
||||||
$board_priority,
|
$this->assertGreaterThan( $board_priority, $dossier_priority,
|
||||||
'Board priority must exceed parent priority.'
|
'Dossier priority must exceed board priority to ensure hookname resolves correctly.' );
|
||||||
);
|
}
|
||||||
$this->assertGreaterThan(
|
|
||||||
$board_priority,
|
/**
|
||||||
$dossier_priority,
|
* Dossier submenu slug must be 'prautoblogger-dossier'.
|
||||||
'Dossier priority must exceed board priority.'
|
*/
|
||||||
|
public function test_dossier_uses_correct_slug(): void {
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
$dossier_page->on_register_menu();
|
||||||
|
|
||||||
|
$found_slug = null;
|
||||||
|
foreach ( $this->call_sequence as $entry ) {
|
||||||
|
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
|
||||||
|
$found_slug = substr( $entry, strlen( 'add_submenu_page:' ) );
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $found_slug,
|
||||||
|
'Dossier must register under PAGE_SLUG.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dossier submenu parent must be 'prautoblogger-settings'.
|
||||||
|
*/
|
||||||
|
public function test_dossier_parent_slug_is_settings(): void {
|
||||||
|
$captured_parent = null;
|
||||||
|
Functions\when( 'add_submenu_page' )->alias(
|
||||||
|
function ( string $parent_slug ) use ( &$captured_parent ) {
|
||||||
|
$captured_parent = $parent_slug;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
$dossier_page->on_register_menu();
|
||||||
|
|
||||||
|
$this->assertSame( 'prautoblogger-settings', $captured_parent,
|
||||||
|
'Dossier must use prautoblogger-settings as parent.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* url_for_post() returns a URL containing the dossier slug and post_id.
|
||||||
|
*/
|
||||||
|
public function test_url_for_post_contains_slug_and_post_id(): void {
|
||||||
|
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
|
||||||
|
$this->assertStringContainsString( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $url );
|
||||||
|
$this->assertStringContainsString( '42', $url );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Shared test harness for Dossier menu registration test suites.
|
|
||||||
*
|
|
||||||
* Provides the Brain\Monkey spy stubs (add_menu_page, add_submenu_page) and the
|
|
||||||
* $submenu isolation that both DossierMenuRegistrationTest and
|
|
||||||
* DossierHooknameMechanismTest depend on.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Admin
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
abstract class DossierMenuRegistrationTestCase extends BaseTestCase {
|
|
||||||
|
|
||||||
/** @var array<int, string> Call sequence spy. */
|
|
||||||
protected array $call_sequence = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captured parent slug from the most recent add_submenu_page call.
|
|
||||||
*
|
|
||||||
* @var string|null
|
|
||||||
*/
|
|
||||||
protected ?string $captured_parent_slug = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captured menu slug from the most recent add_submenu_page call.
|
|
||||||
*
|
|
||||||
* @var string|null
|
|
||||||
*/
|
|
||||||
protected ?string $captured_menu_slug = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hookname returned by the most recent add_submenu_page call.
|
|
||||||
*
|
|
||||||
* In real WordPress this is `{parent_key}_page_{slug}` when parent is known,
|
|
||||||
* or `admin_page_{slug}` when parent is not in $admin_page_hooks. For options.php
|
|
||||||
* children WordPress always emits `admin_page_{slug}`.
|
|
||||||
*
|
|
||||||
* @var string|null
|
|
||||||
*/
|
|
||||||
protected ?string $registration_hookname = null;
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->call_sequence = [];
|
|
||||||
$this->captured_parent_slug = null;
|
|
||||||
$this->captured_menu_slug = null;
|
|
||||||
$this->registration_hookname = null;
|
|
||||||
|
|
||||||
// v0.20.0: the dossier enqueue path localizes the edit/re-run config.
|
|
||||||
Functions\when( 'wp_localize_script' )->justReturn( true );
|
|
||||||
Functions\when( 'wp_create_nonce' )->justReturn( 'test-nonce' );
|
|
||||||
Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } );
|
|
||||||
|
|
||||||
// Spy stub: tracks call order, captures slugs, returns a hookname that
|
|
||||||
// reflects how WordPress actually resolves it for known parents.
|
|
||||||
Functions\when( 'add_menu_page' )->alias(
|
|
||||||
function () {
|
|
||||||
$this->call_sequence[] = 'add_menu_page';
|
|
||||||
return 'prautoblogger-settings';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate WordPress hookname computation:
|
|
||||||
// - options.php parent → 'admin_page_{slug}'
|
|
||||||
// - known plugin parent → '{parent_key}_page_{slug}'
|
|
||||||
// - unknown parent → 'admin_page_{slug}' (orphan fallback)
|
|
||||||
Functions\when( 'add_submenu_page' )->alias(
|
|
||||||
function (
|
|
||||||
string $parent_slug,
|
|
||||||
string $page_title,
|
|
||||||
string $menu_title,
|
|
||||||
string $capability,
|
|
||||||
string $menu_slug,
|
|
||||||
$callback = ''
|
|
||||||
) {
|
|
||||||
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug . ':' . $menu_slug;
|
|
||||||
$this->captured_parent_slug = $parent_slug;
|
|
||||||
$this->captured_menu_slug = $menu_slug;
|
|
||||||
|
|
||||||
if ( 'options.php' === $parent_slug ) {
|
|
||||||
$hookname = 'admin_page_' . $menu_slug;
|
|
||||||
} else {
|
|
||||||
$parent_key = str_replace( '.php', '', $parent_slug );
|
|
||||||
$hookname = $parent_key . '_page_' . $menu_slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->registration_hookname = $hookname;
|
|
||||||
|
|
||||||
// Populate $submenu as WordPress does at registration time.
|
|
||||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
|
|
||||||
global $submenu;
|
|
||||||
$submenu[ $parent_slug ] = $submenu[ $parent_slug ] ?? array();
|
|
||||||
$submenu[ $parent_slug ][] = array( $page_title, $capability, $menu_slug, $menu_title );
|
|
||||||
|
|
||||||
return $hookname;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Functions\when( 'current_user_can' )->justReturn( true );
|
|
||||||
Functions\when( 'get_option' )->justReturn( false );
|
|
||||||
Functions\when( 'admin_url' )->alias(
|
|
||||||
function ( string $path = '' ) {
|
|
||||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( '__' )->returnArg( 1 );
|
|
||||||
|
|
||||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
|
||||||
$GLOBALS['submenu'] = array();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
$this->call_sequence = [];
|
|
||||||
$this->captured_parent_slug = null;
|
|
||||||
$this->captured_menu_slug = null;
|
|
||||||
$this->registration_hookname = null;
|
|
||||||
unset( $GLOBALS['submenu'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -147,7 +147,7 @@ abstract class BaseTestCase extends TestCase {
|
|||||||
*/
|
*/
|
||||||
protected function create_mock_wpdb() {
|
protected function create_mock_wpdb() {
|
||||||
$wpdb = $this->getMockBuilder( \stdClass::class )
|
$wpdb = $this->getMockBuilder( \stdClass::class )
|
||||||
->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'get_col', 'update' ] )
|
->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'update' ] )
|
||||||
->getMock();
|
->getMock();
|
||||||
|
|
||||||
$wpdb->prefix = 'wp_';
|
$wpdb->prefix = 'wp_';
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for the v0.20.0 (B1) request_json persistence in
|
|
||||||
* PRAutoBlogger_Cost_Tracker::log_api_call().
|
|
||||||
*
|
|
||||||
* Every chat call's request body must land on its generation_log row
|
|
||||||
* (stage inputs exist for edit + re-run; the dossier raw-input trace is
|
|
||||||
* no longer hollow — QA M2 F1), with consume-once isolation between rows.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
|
|
||||||
class CostTrackerRequestJsonTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb instance.
|
|
||||||
*/
|
|
||||||
private $wpdb;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rows captured from $wpdb->insert().
|
|
||||||
*
|
|
||||||
* @var array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private array $inserted = array();
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->inserted = array();
|
|
||||||
$this->wpdb = $this->create_mock_wpdb();
|
|
||||||
$GLOBALS['wpdb'] = $this->wpdb;
|
|
||||||
$this->wpdb->insert_id = 7;
|
|
||||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
|
||||||
function ( $table, $row ) {
|
|
||||||
// Capture generation_log rows only — the Logger also
|
|
||||||
// inserts (event_log) when e.g. Pricing warns on an
|
|
||||||
// unknown model; those rows are not under test.
|
|
||||||
if ( false !== strpos( (string) $table, 'generation_log' ) ) {
|
|
||||||
$this->inserted[] = (array) $row;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->stub_get_option( array() );
|
|
||||||
\PRAutoBlogger_Request_Recorder::clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::clear();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* log_api_call() persists the recorder stash into request_json.
|
|
||||||
*/
|
|
||||||
public function test_log_api_call_persists_recorded_request_json(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::record(
|
|
||||||
array(
|
|
||||||
'model' => 'google/gemini-2.5-flash-lite',
|
|
||||||
'messages' => array( array( 'role' => 'user', 'content' => 'draft prompt' ) ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call(
|
|
||||||
null,
|
|
||||||
'draft',
|
|
||||||
'OpenRouter',
|
|
||||||
'google/gemini-2.5-flash-lite',
|
|
||||||
100,
|
|
||||||
500
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertCount( 1, $this->inserted );
|
|
||||||
$row = $this->inserted[0];
|
|
||||||
$this->assertArrayHasKey( 'request_json', $row );
|
|
||||||
$decoded = json_decode( (string) $row['request_json'], true );
|
|
||||||
$this->assertSame( 'draft prompt', $decoded['messages'][0]['content'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume-once isolation: a second log row written with no new send
|
|
||||||
* in between must get NULL, never the previous call's body.
|
|
||||||
*/
|
|
||||||
public function test_second_log_row_without_new_record_gets_null(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) );
|
|
||||||
|
|
||||||
$tracker = new \PRAutoBlogger_Cost_Tracker();
|
|
||||||
$tracker->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 );
|
|
||||||
$tracker->log_api_call( null, 'polish', 'OpenRouter', 'm1', 10, 10 );
|
|
||||||
|
|
||||||
$this->assertCount( 2, $this->inserted );
|
|
||||||
$this->assertNotNull( $this->inserted[0]['request_json'] );
|
|
||||||
$this->assertNull( $this->inserted[1]['request_json'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Historical behavior preserved: nothing recorded -> request_json NULL.
|
|
||||||
*/
|
|
||||||
public function test_log_api_call_without_stash_writes_null(): void {
|
|
||||||
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call(
|
|
||||||
null,
|
|
||||||
'analysis',
|
|
||||||
'OpenRouter',
|
|
||||||
'google/gemini-2.5-flash-lite',
|
|
||||||
10,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertCount( 1, $this->inserted );
|
|
||||||
$this->assertNull( $this->inserted[0]['request_json'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The persisted payload can never contain credential material — the
|
|
||||||
* provider records only build_body() output (headers are separate).
|
|
||||||
* Belt-and-braces audit on the exact column value.
|
|
||||||
*/
|
|
||||||
public function test_persisted_request_json_contains_no_auth_header(): void {
|
|
||||||
$this->stub_get_option( array() );
|
|
||||||
$builder = new \PRAutoBlogger_OpenRouter_Request_Builder();
|
|
||||||
\PRAutoBlogger_Request_Recorder::record(
|
|
||||||
$builder->build_body(
|
|
||||||
array( array( 'role' => 'user', 'content' => 'x' ) ),
|
|
||||||
'google/gemini-2.5-flash-lite',
|
|
||||||
array( 'max_tokens' => 100 )
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call(
|
|
||||||
null,
|
|
||||||
'draft',
|
|
||||||
'OpenRouter',
|
|
||||||
'google/gemini-2.5-flash-lite',
|
|
||||||
10,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
|
|
||||||
$json = (string) $this->inserted[0]['request_json'];
|
|
||||||
$this->assertStringNotContainsString( 'Authorization', $json );
|
|
||||||
$this->assertStringNotContainsString( 'Bearer', $json );
|
|
||||||
$this->assertStringNotContainsString( 'sk-or-', $json );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The half-migrated-schema legacy retry (v0.18.0 pattern) keeps
|
|
||||||
* request_json — the column predates the audit columns (v1.1.0
|
|
||||||
* schema) and exists on every site.
|
|
||||||
*/
|
|
||||||
public function test_legacy_schema_retry_keeps_request_json(): void {
|
|
||||||
$this->inserted = array();
|
|
||||||
$wpdb = $this->create_mock_wpdb();
|
|
||||||
$wpdb->insert_id = 7;
|
|
||||||
$calls = 0;
|
|
||||||
$wpdb->method( 'insert' )->willReturnCallback(
|
|
||||||
function ( $table, $row ) use ( &$calls ) {
|
|
||||||
if ( false === strpos( (string) $table, 'generation_log' ) ) {
|
|
||||||
return 1; // Logger/event_log noise — not under test.
|
|
||||||
}
|
|
||||||
++$calls;
|
|
||||||
$this->inserted[] = (array) $row;
|
|
||||||
return 1 === $calls ? false : 1; // First insert fails (missing v0.18.0 columns).
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$GLOBALS['wpdb'] = $wpdb;
|
|
||||||
|
|
||||||
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) );
|
|
||||||
( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 );
|
|
||||||
|
|
||||||
$this->assertCount( 2, $this->inserted );
|
|
||||||
$retry_row = $this->inserted[1];
|
|
||||||
$this->assertArrayNotHasKey( 'agent_role', $retry_row );
|
|
||||||
$this->assertArrayNotHasKey( 'prompt_version', $retry_row );
|
|
||||||
$this->assertArrayHasKey( 'request_json', $retry_row );
|
|
||||||
$this->assertNotNull( $retry_row['request_json'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Image_Composer_Editor (GD/WP-editor rung).
|
|
||||||
*
|
|
||||||
* Validates the three main branches of cover_crop():
|
|
||||||
* - wp_get_image_editor() returns WP_Error → null returned.
|
|
||||||
* - Resize returns WP_Error → null returned.
|
|
||||||
* - Exact size not reachable (would upscale) → null returned.
|
|
||||||
* - Happy path: successful resize/save returns the expected shape.
|
|
||||||
* And compose_variants() iteration:
|
|
||||||
* - Roles with bad geometry (0-width target) are skipped.
|
|
||||||
* - Null cover_crop result for a role skips that role with a debug log.
|
|
||||||
*
|
|
||||||
* Brain Monkey stubs wp_get_image_editor, get_temp_dir so no filesystem or
|
|
||||||
* WP runtime is needed.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class ImageComposerEditorTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stub get_option so PRAutoBlogger_Logger can construct without Brain Monkey
|
|
||||||
* throwing on the prautoblogger_log_level option lookup. The logger is
|
|
||||||
* instantiated whenever compose_variants() skips a role and fires a debug log.
|
|
||||||
*/
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->stub_get_option( [] ); // Logger reads prautoblogger_log_level; fall back to default.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal layout geometry mirroring Layout::defaults() relevant keys. */
|
|
||||||
private function layout( int $og_w = 1200, int $og_h = 630, int $sq_w = 1080, int $sq_h = 1080 ): array {
|
|
||||||
return array(
|
|
||||||
'og' => array( 'width' => $og_w, 'height' => $og_h ),
|
|
||||||
'square' => array( 'width' => $sq_w, 'height' => $sq_h ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: mock WP image editor whose resize returns a WP_Error. */
|
|
||||||
private function mock_editor_resize_error(): object {
|
|
||||||
$editor = $this->getMockBuilder( \stdClass::class )
|
|
||||||
->addMethods( array( 'resize', 'get_size', 'save' ) )
|
|
||||||
->getMock();
|
|
||||||
$editor->method( 'resize' )->willReturn( new \WP_Error( 'resize_fail', 'Mock resize failure' ) );
|
|
||||||
|
|
||||||
return $editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: mock WP image editor whose save returns a WP_Error. */
|
|
||||||
private function mock_editor_save_error( int $w, int $h ): object {
|
|
||||||
$editor = $this->getMockBuilder( \stdClass::class )
|
|
||||||
->addMethods( array( 'resize', 'get_size', 'save' ) )
|
|
||||||
->getMock();
|
|
||||||
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
|
|
||||||
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
|
|
||||||
$editor->method( 'save' )->willReturn( new \WP_Error( 'save_fail', 'Mock save failure' ) );
|
|
||||||
|
|
||||||
return $editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: mock WP image editor that succeeds at the requested size. */
|
|
||||||
private function mock_editor_success( int $w, int $h ): object {
|
|
||||||
$editor = $this->getMockBuilder( \stdClass::class )
|
|
||||||
->addMethods( array( 'resize', 'get_size', 'save' ) )
|
|
||||||
->getMock();
|
|
||||||
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
|
|
||||||
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
|
|
||||||
$editor->method( 'save' )->willReturn( array( 'path' => '/tmp/prab_ok.png' ) );
|
|
||||||
|
|
||||||
return $editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: mock WP image editor that resizes but returns a different size (upscale guard). */
|
|
||||||
private function mock_editor_wrong_size( int $requested_w, int $requested_h, int $actual_w, int $actual_h ): object {
|
|
||||||
$editor = $this->getMockBuilder( \stdClass::class )
|
|
||||||
->addMethods( array( 'resize', 'get_size', 'save' ) )
|
|
||||||
->getMock();
|
|
||||||
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
|
|
||||||
$editor->method( 'get_size' )->willReturn( array( 'width' => $actual_w, 'height' => $actual_h ) );
|
|
||||||
|
|
||||||
return $editor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// cover_crop() failure paths (tested via compose_variants interface).
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* wp_get_image_editor() returns WP_Error → role skipped,
|
|
||||||
* no variants emitted.
|
|
||||||
*/
|
|
||||||
public function test_editor_wp_error_skips_role(): void {
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
|
|
||||||
Functions\when( 'wp_get_image_editor' )->justReturn(
|
|
||||||
new \WP_Error( 'no_editor', 'No image editor available' )
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
|
|
||||||
|
|
||||||
$this->assertSame( array(), $variants, 'wp_get_image_editor WP_Error must yield no variants' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize step returns WP_Error → role skipped.
|
|
||||||
*/
|
|
||||||
public function test_resize_wp_error_skips_role(): void {
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
|
|
||||||
Functions\when( 'wp_get_image_editor' )->justReturn( $this->mock_editor_resize_error() );
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
|
|
||||||
|
|
||||||
$this->assertSame( array(), $variants, 'Resize WP_Error must yield no variants' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exact size not reachable (WP editor silently gives a different size
|
|
||||||
* instead of upscaling) → role skipped via the size-mismatch guard.
|
|
||||||
*/
|
|
||||||
public function test_size_mismatch_skips_role(): void {
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
|
|
||||||
// Editor resizes to 900×473 when 1080×1080 square was requested (would upscale).
|
|
||||||
Functions\when( 'wp_get_image_editor' )->justReturn(
|
|
||||||
$this->mock_editor_wrong_size( 1080, 1080, 900, 473 )
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_bytes', array( 'square' ), $this->layout() );
|
|
||||||
|
|
||||||
$this->assertSame( array(), $variants, 'Size mismatch (upscale guard) must yield no variants' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save step returns WP_Error → role skipped.
|
|
||||||
*/
|
|
||||||
public function test_save_wp_error_skips_role(): void {
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
|
|
||||||
Functions\when( 'wp_get_image_editor' )->justReturn(
|
|
||||||
$this->mock_editor_save_error( 1200, 630 )
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
|
|
||||||
|
|
||||||
$this->assertSame( array(), $variants, 'Save WP_Error must yield no variants' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Happy path — OG resizes successfully.
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Successful cover_crop for 'og': variant shape matches expectations.
|
|
||||||
* file_put_contents and file_get_contents are exercised via temp file
|
|
||||||
* in the test runner's writable temp dir; the editor mock does not
|
|
||||||
* actually modify the file, but the class reads it back and returns its
|
|
||||||
* bytes.
|
|
||||||
*/
|
|
||||||
public function test_successful_og_resize_returns_correct_shape(): void {
|
|
||||||
$temp_dir = sys_get_temp_dir() . '/';
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( $temp_dir );
|
|
||||||
|
|
||||||
$og_w = 1200;
|
|
||||||
$og_h = 630;
|
|
||||||
|
|
||||||
Functions\when( 'wp_get_image_editor' )->alias(
|
|
||||||
function () use ( $og_w, $og_h ) {
|
|
||||||
return $this->mock_editor_success( $og_w, $og_h );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_source_bytes', array( 'og' ), $this->layout() );
|
|
||||||
|
|
||||||
$this->assertCount( 1, $variants, 'Successful resize must emit exactly one variant' );
|
|
||||||
$variant = $variants[0];
|
|
||||||
$this->assertSame( 'og', $variant['role'] );
|
|
||||||
$this->assertSame( 'image/png', $variant['mime_type'] );
|
|
||||||
$this->assertSame( $og_w, $variant['width'] );
|
|
||||||
$this->assertSame( $og_h, $variant['height'] );
|
|
||||||
$this->assertIsString( $variant['bytes'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// compose_variants() geometry guard.
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A role whose layout target has width=0 is skipped before any I/O —
|
|
||||||
* no editor call, no variant emitted.
|
|
||||||
*/
|
|
||||||
public function test_zero_width_target_skips_role_before_editor(): void {
|
|
||||||
// Make the 'og' slot have zero width so the guard fires before any I/O.
|
|
||||||
$layout = array(
|
|
||||||
'og' => array( 'width' => 0, 'height' => 630 ),
|
|
||||||
);
|
|
||||||
|
|
||||||
// wp_get_image_editor must never be called.
|
|
||||||
Functions\expect( 'wp_get_image_editor' )->never();
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $layout );
|
|
||||||
|
|
||||||
$this->assertSame( array(), $variants );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Role not present in layout skips cleanly.
|
|
||||||
*/
|
|
||||||
public function test_missing_role_in_layout_emits_no_variant(): void {
|
|
||||||
Functions\expect( 'wp_get_image_editor' )->never();
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$editor = new \PRAutoBlogger_Image_Composer_Editor();
|
|
||||||
$variants = $editor->compose_variants( 'fake_bytes', array( 'nonexistent_role' ), array() );
|
|
||||||
|
|
||||||
$this->assertSame( array(), $variants );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -322,43 +322,4 @@ class ImageComposerTest extends BaseTestCase {
|
|||||||
$this->assertStringStartsWith( 'prautoblogger_', $key );
|
$this->assertStringStartsWith( 'prautoblogger_', $key );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ladder rung 2 forced via the override filter ('gd'): compose() emits the
|
|
||||||
* pass-through featured variant as first element, and fires exactly one WARNING.
|
|
||||||
* The Imagick renderer must never be called on this rung.
|
|
||||||
*
|
|
||||||
* Source: QA verdict 2026-06-11-6f76df1.md P2-1 and CTO handoff
|
|
||||||
* convo/prautoblogger/threads/2026-06-maintenance-v0194/01-cto-maintenance-handoff.md item 1.
|
|
||||||
*/
|
|
||||||
public function test_gd_rung_emits_passthrough_featured_and_single_warning(): void {
|
|
||||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'gd' );
|
|
||||||
|
|
||||||
// Imagick renderer must not be invoked on the gd rung.
|
|
||||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
|
||||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
|
||||||
$renderer->expects( $this->never() )->method( 'compose_og' );
|
|
||||||
$renderer->expects( $this->never() )->method( 'compose_square' );
|
|
||||||
|
|
||||||
// Stub the WP functions that cover_crop() calls inside the GD rung.
|
|
||||||
// get_temp_dir() is called first; wp_get_image_editor() returning WP_Error
|
|
||||||
// makes cover_crop() return null for every role → compose_variants() returns []
|
|
||||||
// → the GD rung emits only the pass-through featured (array_merge($passthrough, [])).
|
|
||||||
// This is the real degradation-to-passthrough path the test is designed to assert.
|
|
||||||
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
|
|
||||||
Functions\when( 'wp_get_image_editor' )->justReturn(
|
|
||||||
new \WP_Error( 'no_editor', 'Mock: no GD editor in test environment' )
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
|
|
||||||
return $val instanceof \WP_Error;
|
|
||||||
} );
|
|
||||||
|
|
||||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
|
||||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
|
||||||
|
|
||||||
// Pass-through featured variant is always first.
|
|
||||||
$this->assertNotEmpty( $variants );
|
|
||||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
|
||||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for the v0.20.0 Publisher refresh-unpublished behavior.
|
|
||||||
*
|
|
||||||
* A re-run's regenerated content must land on the existing UNPUBLISHED
|
|
||||||
* post (idempotency previously skip-returned and silently discarded
|
|
||||||
* re-run output); published posts remain frozen and untouched (CPO
|
|
||||||
* guardrail 5 enforced at the data layer).
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class PublisherRerunUpdateTest extends BaseTestCase {
|
|
||||||
|
|
||||||
private \PRAutoBlogger_Article_Idea $idea;
|
|
||||||
private \PRAutoBlogger_Editorial_Review $review;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Args captured from wp_update_post().
|
|
||||||
*
|
|
||||||
* @var array|null
|
|
||||||
*/
|
|
||||||
private $updated_args = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Meta captured from update_post_meta().
|
|
||||||
*
|
|
||||||
* @var array<string, mixed>
|
|
||||||
*/
|
|
||||||
private array $updated_meta = array();
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
$this->idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() );
|
|
||||||
$this->review = new \PRAutoBlogger_Editorial_Review( $this->get_editorial_review_fixture() );
|
|
||||||
|
|
||||||
$this->stub_get_option( array() );
|
|
||||||
Functions\when( 'sanitize_text_field' )->returnArg();
|
|
||||||
Functions\when( 'wp_kses_post' )->returnArg();
|
|
||||||
Functions\when( 'wp_strip_all_tags' )->alias(
|
|
||||||
static function ( $text ) {
|
|
||||||
return trim( strip_tags( (string) $text ) );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
|
||||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
|
||||||
|
|
||||||
$this->updated_args = null;
|
|
||||||
$this->updated_meta = array();
|
|
||||||
Functions\when( 'wp_update_post' )->alias(
|
|
||||||
function ( $args ) {
|
|
||||||
$this->updated_args = (array) $args;
|
|
||||||
return $args['ID'] ?? 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'update_post_meta' )->alias(
|
|
||||||
function ( $post_id, $key, $value ) {
|
|
||||||
$this->updated_meta[ (string) $key ] = $value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// find_existing_post() resolves post 55 for this (run, idea).
|
|
||||||
$wpdb = $this->create_mock_wpdb();
|
|
||||||
$wpdb->method( 'prepare' )->willReturn( 'prepared' );
|
|
||||||
$wpdb->method( 'get_var' )->willReturn( '55' );
|
|
||||||
$GLOBALS['wpdb'] = $wpdb;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stub get_post for post 55 with a status. */
|
|
||||||
private function stub_existing_post( string $status ): void {
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = 55;
|
|
||||||
$post->post_status = $status;
|
|
||||||
Functions\when( 'get_post' )->justReturn( $post );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-run output refreshes the existing draft: content + title are
|
|
||||||
* updated, verdict meta refreshed — and the post status is NEVER
|
|
||||||
* touched (a re-run must not publish; publication stays an explicit
|
|
||||||
* operator action).
|
|
||||||
*/
|
|
||||||
public function test_rerun_refreshes_existing_draft_without_status_change(): void {
|
|
||||||
$this->stub_existing_post( 'draft' );
|
|
||||||
Functions\when( 'wp_insert_post' )->alias(
|
|
||||||
static function () {
|
|
||||||
throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$publisher = new \PRAutoBlogger_Publisher();
|
|
||||||
$post_id = $publisher->save_as_draft( '<p>Regenerated content</p>', $this->idea, $this->review, 'run-1' );
|
|
||||||
|
|
||||||
$this->assertSame( 55, $post_id );
|
|
||||||
$this->assertIsArray( $this->updated_args );
|
|
||||||
$this->assertSame( 55, $this->updated_args['ID'] );
|
|
||||||
$this->assertStringContainsString( 'Regenerated content', (string) $this->updated_args['post_content'] );
|
|
||||||
$this->assertArrayNotHasKey( 'post_status', $this->updated_args );
|
|
||||||
$this->assertSame( $this->review->get_verdict(), $this->updated_meta['_prautoblogger_editor_verdict'] );
|
|
||||||
$this->assertArrayHasKey( '_prautoblogger_generated_at', $this->updated_meta );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Published post: frozen — returned untouched, no wp_update_post, no
|
|
||||||
* meta writes, no duplicate insert (guardrail 5 at the data layer).
|
|
||||||
*/
|
|
||||||
public function test_published_post_is_never_updated(): void {
|
|
||||||
$this->stub_existing_post( 'publish' );
|
|
||||||
Functions\when( 'wp_insert_post' )->alias(
|
|
||||||
static function () {
|
|
||||||
throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' );
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$publisher = new \PRAutoBlogger_Publisher();
|
|
||||||
$post_id = $publisher->save_as_draft( '<p>Regenerated content</p>', $this->idea, $this->review, 'run-1' );
|
|
||||||
|
|
||||||
$this->assertSame( 55, $post_id );
|
|
||||||
$this->assertNull( $this->updated_args );
|
|
||||||
$this->assertSame( array(), $this->updated_meta );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Request_Recorder (v0.20.0 / B1).
|
|
||||||
*
|
|
||||||
* Locks the consume-once + overwrite-on-record semantics that make it
|
|
||||||
* impossible for a stale request body to attach to an unrelated log row,
|
|
||||||
* and the no-Authorization guarantee of the record path.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
|
|
||||||
class RequestRecorderTest extends BaseTestCase {
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
\PRAutoBlogger_Request_Recorder::clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::clear();
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* consume() returns the recorded body exactly once, then null.
|
|
||||||
*/
|
|
||||||
public function test_consume_returns_recorded_body_exactly_once(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::record(
|
|
||||||
array(
|
|
||||||
'model' => 'google/gemini-2.5-flash-lite',
|
|
||||||
'messages' => array( array( 'role' => 'user', 'content' => 'hi' ) ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$first = \PRAutoBlogger_Request_Recorder::consume();
|
|
||||||
$this->assertIsString( $first );
|
|
||||||
$decoded = json_decode( $first, true );
|
|
||||||
$this->assertSame( 'google/gemini-2.5-flash-lite', $decoded['model'] );
|
|
||||||
$this->assertSame( 'hi', $decoded['messages'][0]['content'] );
|
|
||||||
|
|
||||||
// Consume-once: the slot is cleared by the first read.
|
|
||||||
$this->assertNull( \PRAutoBlogger_Request_Recorder::consume() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* record() overwrites the previous stash — a stale body from an
|
|
||||||
* unlogged earlier call can never survive into the next dispatch.
|
|
||||||
*/
|
|
||||||
public function test_record_overwrites_previous_stash(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'first/model' ) );
|
|
||||||
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'second/model' ) );
|
|
||||||
|
|
||||||
$decoded = json_decode( (string) \PRAutoBlogger_Request_Recorder::consume(), true );
|
|
||||||
$this->assertSame( 'second/model', $decoded['model'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* consume() with nothing recorded returns null (historical rows /
|
|
||||||
* non-chat log writes keep request_json NULL).
|
|
||||||
*/
|
|
||||||
public function test_consume_without_record_returns_null(): void {
|
|
||||||
$this->assertNull( \PRAutoBlogger_Request_Recorder::consume() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* clear() empties the slot.
|
|
||||||
*/
|
|
||||||
public function test_clear_empties_slot(): void {
|
|
||||||
\PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm' ) );
|
|
||||||
\PRAutoBlogger_Request_Recorder::clear();
|
|
||||||
$this->assertNull( \PRAutoBlogger_Request_Recorder::consume() );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The full record path for a body built by the real Request_Builder
|
|
||||||
* from poisoned options can never contain credential material:
|
|
||||||
* build_body() copies only whitelisted keys and headers are built
|
|
||||||
* separately, so the recorder's JSON has no Authorization shape.
|
|
||||||
*/
|
|
||||||
public function test_recorded_builder_body_never_contains_auth_material(): void {
|
|
||||||
$this->stub_get_option( array() );
|
|
||||||
|
|
||||||
$builder = new \PRAutoBlogger_OpenRouter_Request_Builder();
|
|
||||||
$body = $builder->build_body(
|
|
||||||
array( array( 'role' => 'user', 'content' => 'write about BPC-157' ) ),
|
|
||||||
'google/gemini-2.5-flash-lite',
|
|
||||||
array(
|
|
||||||
'temperature' => 0.7,
|
|
||||||
'max_tokens' => 4000,
|
|
||||||
// Hostile/accidental injection attempts — must all be dropped.
|
|
||||||
'Authorization' => 'Bearer sk-or-secret-key-123',
|
|
||||||
'headers' => array( 'Authorization' => 'Bearer sk-or-secret-key-123' ),
|
|
||||||
'api_key' => 'sk-or-secret-key-123',
|
|
||||||
// Caller metadata — never sent upstream, never recorded.
|
|
||||||
'stage' => 'draft',
|
|
||||||
'prompt_key' => 'content.draft',
|
|
||||||
'empty_retry' => true,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
\PRAutoBlogger_Request_Recorder::record( $body );
|
|
||||||
|
|
||||||
$json = (string) \PRAutoBlogger_Request_Recorder::consume();
|
|
||||||
$this->assertStringNotContainsString( 'Authorization', $json );
|
|
||||||
$this->assertStringNotContainsString( 'Bearer', $json );
|
|
||||||
$this->assertStringNotContainsString( 'sk-or-', $json );
|
|
||||||
$this->assertStringNotContainsString( 'api_key', $json );
|
|
||||||
$this->assertStringNotContainsString( 'empty_retry', $json );
|
|
||||||
$this->assertStringNotContainsString( 'prompt_key', $json );
|
|
||||||
|
|
||||||
$decoded = json_decode( $json, true );
|
|
||||||
$this->assertSame(
|
|
||||||
array( 'model', 'messages', 'temperature', 'max_tokens' ),
|
|
||||||
array_keys( $decoded )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Rerun_Eligibility (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Locks the published-post lockout (CPO guardrail 5 incl. future/private),
|
|
||||||
* the actively-executing-run block, the editable-stage policy, and the
|
|
||||||
* downstream/rebuild set computation that drives stale marking.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class RerunEligibilityTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
|
||||||
*/
|
|
||||||
private $wpdb;
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->wpdb = $this->create_mock_wpdb();
|
|
||||||
$this->wpdb->options = 'wp_options';
|
|
||||||
$GLOBALS['wpdb'] = $this->wpdb;
|
|
||||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
||||||
static function ( $sql, ...$args ) {
|
|
||||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', array_filter( $args, 'is_scalar' ) ) ) . ' */';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire the mock for: runs table exists, run row with given status,
|
|
||||||
* generation lock free.
|
|
||||||
*/
|
|
||||||
private function wire_run( string $run_status ): void {
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
|
||||||
// Both runs + stage_inputs probes succeed.
|
|
||||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
|
||||||
return 'wp_prautoblogger_stage_inputs';
|
|
||||||
}
|
|
||||||
return 'wp_prautoblogger_runs';
|
|
||||||
}
|
|
||||||
return null; // Lock option absent -> lock free; seed absent.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturn(
|
|
||||||
array(
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'status' => $run_status,
|
|
||||||
'ceiling_usd' => '0.50',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stub get_post to return a post with the given status. */
|
|
||||||
private function stub_post( string $status ): void {
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = 99;
|
|
||||||
$post->post_status = $status;
|
|
||||||
Functions\when( 'get_post' )->justReturn( $post );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Published, scheduled, and private posts are all frozen — no
|
|
||||||
* edit+rerun (CPO guardrail 5). Draft and pending are not.
|
|
||||||
*/
|
|
||||||
public function test_published_post_lockout_covers_all_frozen_statuses(): void {
|
|
||||||
foreach ( array( 'publish', 'future', 'private' ) as $frozen ) {
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->post_status = $frozen;
|
|
||||||
$this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$frozen} must freeze" );
|
|
||||||
}
|
|
||||||
foreach ( array( 'draft', 'pending' ) as $open ) {
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->post_status = $open;
|
|
||||||
$this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$open} must not freeze" );
|
|
||||||
}
|
|
||||||
// A run with no post yet is not frozen.
|
|
||||||
$this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( null ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* check() rejects a published post with the WP-editor redirect copy
|
|
||||||
* (server-side enforcement, not just hidden UI).
|
|
||||||
*/
|
|
||||||
public function test_check_rejects_published_post(): void {
|
|
||||||
$this->wire_run( 'done' );
|
|
||||||
$this->stub_post( 'publish' );
|
|
||||||
|
|
||||||
$verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 );
|
|
||||||
|
|
||||||
$this->assertFalse( $verdict['ok'] );
|
|
||||||
$this->assertStringContainsString( 'published', $verdict['reason'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* check() rejects an actively executing run — "in progress" in the
|
|
||||||
* CPO sense means unpublished workflow state, not mid-execution.
|
|
||||||
*/
|
|
||||||
public function test_check_rejects_actively_executing_run(): void {
|
|
||||||
$this->wire_run( 'running' );
|
|
||||||
$this->stub_post( 'draft' );
|
|
||||||
|
|
||||||
$verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 );
|
|
||||||
|
|
||||||
$this->assertFalse( $verdict['ok'] );
|
|
||||||
$this->assertStringContainsString( 'currently executing', $verdict['reason'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A terminal run on an unpublished post is eligible: done, failed
|
|
||||||
* and halted (held) runs can all be re-run.
|
|
||||||
*/
|
|
||||||
public function test_check_accepts_terminal_runs_on_unpublished_posts(): void {
|
|
||||||
foreach ( array( 'done', 'failed', 'halted' ) as $status ) {
|
|
||||||
$wpdb = $this->create_mock_wpdb();
|
|
||||||
$wpdb->options = 'wp_options';
|
|
||||||
$wpdb->method( 'prepare' )->willReturnCallback( static fn( $sql, ...$a ) => (string) $sql );
|
|
||||||
$wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
return ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) ? 'wp_prautoblogger_runs' : null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => $status ) );
|
|
||||||
$GLOBALS['wpdb'] = $wpdb;
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
$this->stub_post( 'draft' );
|
|
||||||
|
|
||||||
$verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 );
|
|
||||||
$this->assertTrue( $verdict['ok'], "run status {$status} must be eligible: {$verdict['reason']}" );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Editable-stage policy: writer chat stages only. review/analysis/
|
|
||||||
* image stages get the replay affordance refused at the policy layer.
|
|
||||||
*/
|
|
||||||
public function test_editable_stage_policy(): void {
|
|
||||||
foreach ( array( 'outline', 'draft', 'polish' ) as $stage ) {
|
|
||||||
$this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) );
|
|
||||||
}
|
|
||||||
foreach ( array( 'review', 'publish', 'analysis', 'research', 'llm_research', 'image_a', 'image_b', 'curate' ) as $stage ) {
|
|
||||||
$this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
$verdict = \PRAutoBlogger_Rerun_Eligibility::check_replay( 'run-1', 99, 'review', 'editor', 'idea:abc' );
|
|
||||||
$this->assertFalse( $verdict['ok'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downstream/rebuild sets follow the canonical chain — these sets
|
|
||||||
* drive stale marking and demotion, so order and bounds matter.
|
|
||||||
*/
|
|
||||||
public function test_downstream_and_rebuild_sets(): void {
|
|
||||||
$this->assertSame( array( 'polish', 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'draft' ) );
|
|
||||||
$this->assertSame( array( 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'review' ) );
|
|
||||||
$this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'publish' ) );
|
|
||||||
$this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'image_a' ) );
|
|
||||||
$this->assertSame( array( 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'review' ) );
|
|
||||||
$this->assertSame(
|
|
||||||
array( 'outline', 'draft', 'polish', 'review', 'publish' ),
|
|
||||||
\PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'outline' )
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Rerun_Executor + Job_Support (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Locks the chained-cron contract (queue() schedules + writes the
|
|
||||||
* queued transient — nothing executes synchronously), the under-lock
|
|
||||||
* re-validation (a post published between click and pickup aborts the
|
|
||||||
* job with ZERO stage mutations), and the terminal-status restore when
|
|
||||||
* the stage demotion fails after the run was reopened.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
use Brain\Monkey\Functions;
|
|
||||||
|
|
||||||
class RerunExecutorTest extends BaseTestCase {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
|
||||||
*/
|
|
||||||
private $wpdb;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SQL captured from $wpdb->query().
|
|
||||||
*
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
private array $queries = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transients captured from set_transient().
|
|
||||||
*
|
|
||||||
* @var array<int, array>
|
|
||||||
*/
|
|
||||||
private array $transients = array();
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
$this->queries = array();
|
|
||||||
$this->transients = array();
|
|
||||||
$this->wpdb = $this->create_mock_wpdb();
|
|
||||||
$this->wpdb->options = 'wp_options';
|
|
||||||
$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 ) ) . ' */';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'set_transient' )->alias(
|
|
||||||
function ( $key, $value, $ttl = 0 ) {
|
|
||||||
$this->transients[] = (array) $value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Monthly budget 0 = no limit (fast false path).
|
|
||||||
$this->stub_get_option( array( 'prautoblogger_monthly_budget_usd' => '0' ) );
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
|
||||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SHOW TABLES probes succeed for every substrate table. */
|
|
||||||
private function tables_exist(): void {
|
|
||||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
|
||||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
|
||||||
return 'wp_prautoblogger_stage_inputs';
|
|
||||||
}
|
|
||||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
|
||||||
return 'wp_prautoblogger_run_stages';
|
|
||||||
}
|
|
||||||
return 'wp_prautoblogger_runs';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* queue() never executes anything: it schedules a single cron event
|
|
||||||
* with a uniqueness token appended and broadcasts a 'running' status
|
|
||||||
* transient so board/dossier polling shows the queued state
|
|
||||||
* (chained-cron semantics — the CPO hard constraint).
|
|
||||||
*/
|
|
||||||
public function test_queue_schedules_cron_and_broadcasts_queued_state(): void {
|
|
||||||
$scheduled = null;
|
|
||||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
|
||||||
static function ( $ts, $hook, $args ) use ( &$scheduled ) {
|
|
||||||
$scheduled = array(
|
|
||||||
'hook' => $hook,
|
|
||||||
'args' => $args,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Functions\when( 'spawn_cron' )->justReturn( true );
|
|
||||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
|
||||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
|
||||||
|
|
||||||
\PRAutoBlogger_Rerun_Job_Support::queue(
|
|
||||||
\PRAutoBlogger_Rerun_Executor::REPLAY_ACTION,
|
|
||||||
array( 'run-1', 99, 'draft', 'writer', 'idea:abc' ),
|
|
||||||
'Queued: re-running Draft…'
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $scheduled['hook'] );
|
|
||||||
$this->assertCount( 6, $scheduled['args'] ); // 5 args + uniqueness token.
|
|
||||||
$this->assertSame( 'run-1', $scheduled['args'][0] );
|
|
||||||
$this->assertCount( 1, $this->transients );
|
|
||||||
$this->assertSame( 'running', $this->transients[0]['status'] );
|
|
||||||
$this->assertSame( 'Queued: re-running Draft…', $this->transients[0]['stage'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Under-lock re-validation: a post published between click and cron
|
|
||||||
* pickup aborts the replay with an error transient and ZERO
|
|
||||||
* run_stages mutations (guardrail 5 cannot be raced).
|
|
||||||
*/
|
|
||||||
public function test_replay_job_aborts_on_frozen_post_without_mutations(): void {
|
|
||||||
$this->tables_exist();
|
|
||||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
$this->queries[] = (string) $sql;
|
|
||||||
return 1; // Lock cleanup + acquire succeed.
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = 99;
|
|
||||||
$post->post_status = 'publish';
|
|
||||||
Functions\when( 'get_post' )->justReturn( $post );
|
|
||||||
|
|
||||||
( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' );
|
|
||||||
|
|
||||||
foreach ( $this->queries as $sql ) {
|
|
||||||
$this->assertStringNotContainsString( 'run_stages', $sql, 'no stage mutation may happen on a frozen post' );
|
|
||||||
$this->assertStringNotContainsString( 'run_id = %s AND status IN', $sql, 'the run must not be reopened' );
|
|
||||||
}
|
|
||||||
$last = end( $this->transients );
|
|
||||||
$this->assertSame( 'error', $last['status'] );
|
|
||||||
$this->assertStringContainsString( 'published', $last['message'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the stage demotion fails after the run was reopened (e.g. the
|
|
||||||
* row is concurrently 'running'), the previous terminal status is
|
|
||||||
* restored — the run cannot be left dangling in 'running' for the
|
|
||||||
* reaper to misread.
|
|
||||||
*/
|
|
||||||
public function test_replay_job_restores_terminal_status_when_restart_fails(): void {
|
|
||||||
$this->tables_exist();
|
|
||||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
|
||||||
static function ( $sql ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
|
||||||
return array(
|
|
||||||
'version' => '2',
|
|
||||||
'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return array(
|
|
||||||
'run_id' => 'run-1',
|
|
||||||
'status' => 'done',
|
|
||||||
'ceiling_usd' => '0.50',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
$sql = (string) $sql;
|
|
||||||
$this->queries[] = $sql;
|
|
||||||
if ( false !== strpos( $sql, 'attempt = attempt + 1' ) ) {
|
|
||||||
return 0; // restart() fails — row not demotable.
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$post = new \WP_Post();
|
|
||||||
$post->ID = 99;
|
|
||||||
$post->post_status = 'draft';
|
|
||||||
Functions\when( 'get_post' )->justReturn( $post );
|
|
||||||
|
|
||||||
( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' );
|
|
||||||
|
|
||||||
$reopened = false;
|
|
||||||
$restored = false;
|
|
||||||
foreach ( $this->queries as $sql ) {
|
|
||||||
if ( false !== strpos( $sql, "ceiling_usd = %f" ) ) {
|
|
||||||
$reopened = true;
|
|
||||||
}
|
|
||||||
// restore_terminal -> mark_status('done') over the reopened 'running' row.
|
|
||||||
if ( false !== strpos( $sql, 'SET status = %s' ) && false !== strpos( $sql, 'done' )
|
|
||||||
&& false !== strpos( $sql, "status IN ('pending','running')" ) ) {
|
|
||||||
$restored = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->assertTrue( $reopened, 'run must have been reopened before restart' );
|
|
||||||
$this->assertTrue( $restored, 'terminal status must be restored after the aborted demotion' );
|
|
||||||
$last = end( $this->transients );
|
|
||||||
$this->assertSame( 'error', $last['status'] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,8 +110,6 @@ class ResearchReaperTest extends BaseTestCase {
|
|||||||
'model' => $o->model ?? 'gemini-flash',
|
'model' => $o->model ?? 'gemini-flash',
|
||||||
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
||||||
'completion_tokens' => $o->completion_tokens ?? 500,
|
'completion_tokens' => $o->completion_tokens ?? 500,
|
||||||
'prompt_version' => $o->prompt_version ?? null,
|
|
||||||
'agent_role' => $o->agent_role ?? '',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
<?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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Run_State::reopen() (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Locks: terminal-only atomic gate, ceiling re-snapshot from the
|
|
||||||
* CURRENT setting, and — critical for CPO guardrail 4 — that the
|
|
||||||
* accumulated reserved/settled spend is never reset, so re-runs are
|
|
||||||
* new spend on the same run ledger.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
|
|
||||||
class RunStateReopenTest 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_runs' );
|
|
||||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
|
||||||
static function ( $sql, ...$args ) {
|
|
||||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
|
||||||
}
|
|
||||||
);
|
|
||||||
$this->stub_get_option( array( 'prautoblogger_per_run_cost_ceiling_usd' => '0.75' ) );
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void {
|
|
||||||
\PRAutoBlogger_Run_State::flush_cache();
|
|
||||||
unset( $GLOBALS['wpdb'] );
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* reopen() transitions ONLY terminal runs (atomic conditional UPDATE)
|
|
||||||
* and re-snapshots the ceiling from the current setting.
|
|
||||||
*/
|
|
||||||
public function test_reopen_gates_on_terminal_status_and_resnapshots_ceiling(): void {
|
|
||||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
$this->queries[] = (string) $sql;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertTrue( \PRAutoBlogger_Run_State::reopen( 'run-1' ) );
|
|
||||||
|
|
||||||
$sql = $this->queries[0];
|
|
||||||
$this->assertStringContainsString( "SET status = 'running'", $sql );
|
|
||||||
$this->assertStringContainsString( "status IN ('done','failed','halted')", $sql );
|
|
||||||
$this->assertStringContainsString( 'ceiling_usd = %f', $sql );
|
|
||||||
$this->assertStringContainsString( '0.75', $sql ); // Current setting value.
|
|
||||||
$this->assertStringContainsString( 'finished_at = NULL', $sql );
|
|
||||||
// Guardrail 4: accumulated spend is NEVER reset by a reopen.
|
|
||||||
$this->assertStringNotContainsString( 'reserved_usd', $sql );
|
|
||||||
$this->assertStringNotContainsString( 'settled_usd', $sql );
|
|
||||||
$this->assertStringNotContainsString( 'overage_usd', $sql );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No row matched (run is pending/running or absent) -> false, so the
|
|
||||||
* executor aborts instead of replaying against a closed gate.
|
|
||||||
*/
|
|
||||||
public function test_reopen_returns_false_when_not_terminal(): void {
|
|
||||||
$this->wpdb->method( 'query' )->willReturn( 0 );
|
|
||||||
|
|
||||||
$this->assertFalse( \PRAutoBlogger_Run_State::reopen( 'run-1' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-healing: missing table / empty run id -> false, no query.
|
|
||||||
*/
|
|
||||||
public function test_reopen_noop_paths(): void {
|
|
||||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
|
||||||
function ( $sql ) {
|
|
||||||
$this->queries[] = (string) $sql;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertFalse( \PRAutoBlogger_Run_State::reopen( '' ) );
|
|
||||||
$this->assertCount( 0, $this->queries );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
<?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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Tests for PRAutoBlogger_Stage_Replay (v0.20.0, M3).
|
|
||||||
*
|
|
||||||
* Locks the body -> options inverse transform (the replay must
|
|
||||||
* round-trip through Request_Builder::build_body() to the SAME wire
|
|
||||||
* shape, including the v0.18.1 reasoning headroom) and the decode
|
|
||||||
* validation that guards the replay path against non-chat payloads.
|
|
||||||
*
|
|
||||||
* @package PRAutoBlogger\Tests\Core
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Core;
|
|
||||||
|
|
||||||
use PRAutoBlogger\Tests\BaseTestCase;
|
|
||||||
|
|
||||||
class StageReplayTest extends BaseTestCase {
|
|
||||||
|
|
||||||
protected function setUp(): void {
|
|
||||||
parent::setUp();
|
|
||||||
// Builder reads the global reasoning setting; pin it OFF here and
|
|
||||||
// flip it in the drift test below.
|
|
||||||
$this->stub_get_option( array( 'prautoblogger_reasoning_enabled' => '0' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round-trip: a body WITHOUT reasoning replayed through build_body()
|
|
||||||
* reproduces the original wire shape byte-for-byte.
|
|
||||||
*/
|
|
||||||
public function test_round_trip_without_reasoning(): void {
|
|
||||||
$original = array(
|
|
||||||
'model' => 'google/gemini-2.5-flash-lite',
|
|
||||||
'messages' => array(
|
|
||||||
array( 'role' => 'system', 'content' => 'sys' ),
|
|
||||||
array( 'role' => 'user', 'content' => 'edited prompt' ),
|
|
||||||
),
|
|
||||||
'temperature' => 0.7,
|
|
||||||
'max_tokens' => 4000,
|
|
||||||
);
|
|
||||||
|
|
||||||
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' );
|
|
||||||
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body(
|
|
||||||
$original['messages'],
|
|
||||||
$original['model'],
|
|
||||||
$options
|
|
||||||
);
|
|
||||||
|
|
||||||
// The explicit reasoning-off pin is the only permitted delta
|
|
||||||
// (same shape the empty-completion retry sends upstream).
|
|
||||||
$this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] );
|
|
||||||
unset( $rebuilt['reasoning'] );
|
|
||||||
$this->assertSame( $original, array_merge( array( 'model' => $original['model'], 'messages' => $original['messages'] ), $rebuilt ) );
|
|
||||||
$this->assertSame( 4000, $rebuilt['max_tokens'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round-trip WITH reasoning: the stored body's max_tokens carries the
|
|
||||||
* builder's +cap headroom; the inverse subtracts it so the rebuilt
|
|
||||||
* body is identical (headroom applied once, never compounded).
|
|
||||||
*/
|
|
||||||
public function test_round_trip_with_reasoning_headroom(): void {
|
|
||||||
$original = array(
|
|
||||||
'model' => 'deepseek/deepseek-v4-flash',
|
|
||||||
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
|
|
||||||
'max_tokens' => 6048, // Caller asked 4000; builder added the 2048 cap.
|
|
||||||
'reasoning' => array(
|
|
||||||
'enabled' => true,
|
|
||||||
'max_tokens' => 2048,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' );
|
|
||||||
$this->assertSame( 4000, $options['max_tokens'] );
|
|
||||||
|
|
||||||
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body(
|
|
||||||
$original['messages'],
|
|
||||||
$original['model'],
|
|
||||||
$options
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame( 6048, $rebuilt['max_tokens'] );
|
|
||||||
$this->assertSame( $original['reasoning'], $rebuilt['reasoning'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global-setting drift cannot reshape a replay: even with reasoning
|
|
||||||
* globally ENABLED, a body stored without reasoning replays with the
|
|
||||||
* explicit off-pin (build_body honours the per-call override).
|
|
||||||
*/
|
|
||||||
public function test_global_reasoning_drift_cannot_leak_into_replay(): void {
|
|
||||||
$this->stub_get_option(
|
|
||||||
array(
|
|
||||||
'prautoblogger_reasoning_enabled' => '1',
|
|
||||||
'prautoblogger_reasoning_effort' => 'high',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$body = array(
|
|
||||||
'model' => 'google/gemini-2.5-flash-lite',
|
|
||||||
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
|
|
||||||
'max_tokens' => 1000,
|
|
||||||
);
|
|
||||||
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'polish' );
|
|
||||||
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options );
|
|
||||||
|
|
||||||
$this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] );
|
|
||||||
$this->assertSame( 1000, $rebuilt['max_tokens'] ); // No headroom added.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caller metadata (stage/prompt_key) rides options for the guard's
|
|
||||||
* audit rows but never reaches the wire body.
|
|
||||||
*/
|
|
||||||
public function test_options_carry_stage_metadata_not_sent_upstream(): void {
|
|
||||||
$body = array(
|
|
||||||
'model' => 'm',
|
|
||||||
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
|
|
||||||
);
|
|
||||||
$options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'draft' );
|
|
||||||
|
|
||||||
$this->assertSame( 'draft', $options['stage'] );
|
|
||||||
$this->assertSame( 'content.draft', $options['prompt_key'] );
|
|
||||||
|
|
||||||
$rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options );
|
|
||||||
$this->assertArrayNotHasKey( 'stage', $rebuilt );
|
|
||||||
$this->assertArrayNotHasKey( 'prompt_key', $rebuilt );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* decode_body() accepts only well-formed chat bodies — model string +
|
|
||||||
* non-empty role/content message list. Everything else returns null
|
|
||||||
* (the executor aborts cleanly instead of replaying garbage).
|
|
||||||
*/
|
|
||||||
public function test_decode_body_validation(): void {
|
|
||||||
$valid = wp_json_encode(
|
|
||||||
array(
|
|
||||||
'model' => 'm',
|
|
||||||
'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
$this->assertIsArray( \PRAutoBlogger_Stage_Replay::decode_body( (string) $valid ) );
|
|
||||||
|
|
||||||
$invalid = array(
|
|
||||||
'not json at all',
|
|
||||||
'"just a string"',
|
|
||||||
'{"messages":[{"role":"user","content":"p"}]}', // No model.
|
|
||||||
'{"model":"m"}', // No messages.
|
|
||||||
'{"model":"m","messages":[]}', // Empty messages.
|
|
||||||
'{"model":"m","messages":["plain string"]}', // Malformed message.
|
|
||||||
'{"model":"m","messages":[{"role":"user"}]}', // No content.
|
|
||||||
'{"model":"m","messages":[{"role":1,"content":"p"}]}', // Non-string role.
|
|
||||||
);
|
|
||||||
foreach ( $invalid as $payload ) {
|
|
||||||
$this->assertNull( \PRAutoBlogger_Stage_Replay::decode_body( $payload ), "must reject: {$payload}" );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,8 +37,6 @@ $tables = array(
|
|||||||
$prefix . 'run_decisions',
|
$prefix . 'run_decisions',
|
||||||
$prefix . 'runs',
|
$prefix . 'runs',
|
||||||
$prefix . 'run_stages',
|
$prefix . 'run_stages',
|
||||||
// Edit + re-run input versions (v0.20.0 / db 1.3.0).
|
|
||||||
$prefix . 'stage_inputs',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ( $tables as $table ) {
|
foreach ( $tables as $table ) {
|
||||||
@@ -95,8 +93,6 @@ $hooks = array(
|
|||||||
'prautoblogger_generate_queued_article',
|
'prautoblogger_generate_queued_article',
|
||||||
'prautoblogger_generate_from_idea',
|
'prautoblogger_generate_from_idea',
|
||||||
'prautoblogger_opik_dispatch',
|
'prautoblogger_opik_dispatch',
|
||||||
'prautoblogger_rerun_stage_replay',
|
|
||||||
'prautoblogger_rerun_from_stage',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ( $hooks as $hook ) {
|
foreach ( $hooks as $hook ) {
|
||||||
|
|||||||
Reference in New Issue
Block a user