From 6ed639d1db30b5d93be526c1890ffeeb9802d918 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 12 Jun 2026 13:40:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(M3):=20dossier=20edit+rerun=20UI,=20F2/F3,?= =?UTF-8?q?=20run-list=20badges=20=E2=80=94=20v0.20.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dossier_Actions (4 AJAX endpoints, manage_options+nonce): save fork / queue replay / queue rebuild / stage-status poll. Run identity always derived server-side from post meta; the AJAX layer validates + queues ONLY — all mutations happen in the cron handlers (chained-cron, CPO hard constraint). Fork saves lock message structure (count/roles) so every version stays replayable. - Edit panel per editable stage: per-message textareas prefilled from the latest fork or the recorded request_json; explicit fork + downstream-stale + queued-execution copy; spend strip with ceiling warning (filterable fraction, default 0.8). Disabled stages always show WHY (policy reasons; missing-input retention copy). - Stale / human-edited / attempt chips per stage + header badges (M2 slot filled); dossier polls stage status while a re-run is active (board poll interval setting) and re-renders when the run settles. - F2: sidebar Models & Prompts card (per-stage model/pv/role) + Run Spend card. F3: log-only stages (image_a/b, llm_research, ...) render from generation_log + the _prautoblogger_image_role attachment grid. - M2 correctness: dossier is now item-scoped (idea-hash meta) and excludes gen_log rows linked to other posts of a multi-article run. - Run-list visibility (product AC): board cards get a Human-modified chip; Review Queue rows get a stale-stages chip linking to the dossier. Both via single batched queries (no N+1). - v0.20.0 + CHANGELOG + ARCHITECTURE #24 (+ file tree, schema tables, db-version note) in this commit per versioning discipline. Agent-Session: engineer-prab-m3-20260612 --- ARCHITECTURE.md | 114 ++++++- CHANGELOG.md | 71 +++++ assets/css/dossier-edit.css | 240 ++++++++++++++ assets/js/board.js | 5 + assets/js/dossier-edit.js | 235 ++++++++++++++ includes/admin/class-board-data-provider.php | 44 ++- includes/admin/class-board-page.php | 1 + includes/admin/class-dossier-actions.php | 299 ++++++++++++++++++ .../admin/class-dossier-data-assembler.php | 104 +++++- includes/admin/class-dossier-image-data.php | 82 +++++ includes/admin/class-dossier-page.php | 36 +++ .../admin/class-dossier-rerun-panel-data.php | 181 +++++++++++ includes/class-prautoblogger.php | 7 + includes/core/class-rerun-executor.php | 1 - includes/core/class-run-stage-state.php | 30 +- prautoblogger.php | 5 +- templates/admin/dossier-edit-panel.php | 109 +++++++ templates/admin/dossier-log-stage-section.php | 118 +++++++ templates/admin/dossier-page.php | 19 ++ templates/admin/dossier-sidebar-cards.php | 86 +++++ templates/admin/dossier-stage-section.php | 51 ++- templates/admin/review-queue.php | 17 + .../unit/Admin/BoardHumanModifiedFlagTest.php | 120 +++++++ tests/unit/Admin/DossierActionsRerunTest.php | 103 ++++++ .../Admin/DossierActionsSaveInputTest.php | 87 +++++ tests/unit/Admin/DossierActionsTestCase.php | 212 +++++++++++++ .../Admin/DossierAssemblerRerunDataTest.php | 236 ++++++++++++++ tests/unit/Admin/DossierDataAssemblerTest.php | 8 + .../Admin/DossierMenuRegistrationTestCase.php | 5 + tests/unit/BaseTestCase.php | 2 +- 30 files changed, 2606 insertions(+), 22 deletions(-) create mode 100644 assets/css/dossier-edit.css create mode 100644 assets/js/dossier-edit.js create mode 100644 includes/admin/class-dossier-actions.php create mode 100644 includes/admin/class-dossier-image-data.php create mode 100644 includes/admin/class-dossier-rerun-panel-data.php create mode 100644 templates/admin/dossier-edit-panel.php create mode 100644 templates/admin/dossier-log-stage-section.php create mode 100644 templates/admin/dossier-sidebar-cards.php create mode 100644 tests/unit/Admin/BoardHumanModifiedFlagTest.php create mode 100644 tests/unit/Admin/DossierActionsRerunTest.php create mode 100644 tests/unit/Admin/DossierActionsSaveInputTest.php create mode 100644 tests/unit/Admin/DossierActionsTestCase.php create mode 100644 tests/unit/Admin/DossierAssemblerRerunDataTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 323a92f..581ad5f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -121,17 +121,19 @@ prautoblogger/ │ │ ├── admin.css # Admin page styles (wp-admin conventions) │ │ ├── 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-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) │ └── js/ │ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js) │ ├── 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-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) │ ├── includes/ -│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; split v0.19.2) +│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (split v0.19.2) │ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1) -│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages) +│ ├── class-pipeline-schema-installer.php # Pipeline substrate tables (prompts, run_sources, run_decisions, runs, run_stages, stage_inputs) │ ├── 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-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run @@ -148,6 +150,9 @@ prautoblogger/ │ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3) │ │ ├── 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-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-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources) │ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images @@ -194,7 +199,15 @@ prautoblogger/ │ │ ├── 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-ceiling-exception.php # Thrown on ceiling breach (run already halted) -│ │ ├── class-run-stage-state.php # Per-run per-stage state machine (idempotent resume, output snapshots) +│ │ ├── class-run-stage-state.php # Per-run per-stage state machine: read API + thin write proxies (split v0.20.0) +│ │ ├── 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-audit-writer.php # run_sources / run_decisions insert layer │ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker) @@ -252,6 +265,10 @@ prautoblogger/ │ └── admin/ │ ├── 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-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) │ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2) │ ├── settings-page.php # Settings page template (tabbed sidebar layout) @@ -532,6 +549,7 @@ dropped on uninstall. | verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' | | rationale | TEXT | Decision rationale (nullable) | | 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 | - INDEX: `run_id` @@ -568,12 +586,38 @@ dropped on uninstall. | attempt | SMALLINT UNSIGNED | Re-entry counter | | 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) | +| 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 | | updated_at | DATETIME | Last transition | | finished_at | DATETIME | Set on done/failed (nullable) | - 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`) All prefixed with `prautoblogger_`: @@ -964,6 +1008,70 @@ 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` 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 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf4fbf..88f1332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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 diff --git a/assets/css/dossier-edit.css b/assets/css/dossier-edit.css new file mode 100644 index 0000000..ea6bc4b --- /dev/null +++ b/assets/css/dossier-edit.css @@ -0,0 +1,240 @@ +/** + * 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; +} diff --git a/assets/js/board.js b/assets/js/board.js index 38f697a..a58f31a 100644 --- a/assets/js/board.js +++ b/assets/js/board.js @@ -69,6 +69,11 @@ html += '
' + escHtml( card.title || cfg.strings.empty ) + '
'; + // v0.20.0: human-modified runs are visually distinct at run-list level. + if ( card.human_modified ) { + html += '' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + ''; + } + if ( card.cost_total ) { html += '
'; html += escHtml( formatCost( card.cost_total ) ); diff --git a/assets/js/dossier-edit.js b/assets/js/dossier-edit.js new file mode 100644 index 0000000..09ac2ec --- /dev/null +++ b/assets/js/dossier-edit.js @@ -0,0 +1,235 @@ +/** + * 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(); + } + } ); +}() ); diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php index d2fbf15..1b4c7ec 100644 --- a/includes/admin/class-board-data-provider.php +++ b/includes/admin/class-board-data-provider.php @@ -127,7 +127,7 @@ class PRAutoBlogger_Board_Data_Provider { ); } - return $cards; + return $this->flag_human_modified( $cards ); } /** @@ -183,6 +183,48 @@ class PRAutoBlogger_Board_Data_Provider { ); } + return $this->flag_human_modified( $cards ); + } + + /** + * Set 'human_modified' on each card in ONE batched query (v0.20.0 — + * CPO product AC: human-modified runs are visually distinct at the + * run-list level). No per-card queries; self-healing on a missing + * table/column (flag simply stays false). + * + * @param array> $cards Column cards. + * @return array> 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; } } diff --git a/includes/admin/class-board-page.php b/includes/admin/class-board-page.php index 02ac089..4b1e3c0 100644 --- a/includes/admin/class-board-page.php +++ b/includes/admin/class-board-page.php @@ -152,6 +152,7 @@ class PRAutoBlogger_Board_Page { 'edit' => __( 'Edit', 'prautoblogger' ), 'viewLog' => __( 'View Log', 'prautoblogger' ), 'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ), + 'humanModified' => __( 'Human-modified', 'prautoblogger' ), ), ) ); diff --git a/includes/admin/class-dossier-actions.php b/includes/admin/class-dossier-actions.php new file mode 100644 index 0000000..bce0a65 --- /dev/null +++ b/includes/admin/class-dossier-actions.php @@ -0,0 +1,299 @@ + 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 $request Resolved request context. + * @return array|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 $base Decoded base body. + * @param array|null $messages Edited messages payload. + * @return array|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; + } +} diff --git a/includes/admin/class-dossier-data-assembler.php b/includes/admin/class-dossier-data-assembler.php index 23a8359..718563a 100644 --- a/includes/admin/class-dossier-data-assembler.php +++ b/includes/admin/class-dossier-data-assembler.php @@ -16,6 +16,15 @@ declare(strict_types=1); * Legacy posts (no run row) return ['has_run' => false] -- never notices. * Amortized research rows (pv=null, role='') render gracefully. * + * v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key + * from the idea-hash meta; multi-article runs no longer collide) and + * gen_log rows exclude entries linked to OTHER posts; per-stage edit/ + * re-run affordance data + run spend ride the view model + * (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/ + * llm_research/... -- generation_log rows with no run_stages row) and + * the post's pipeline attachments are exposed so the image sections + * render from their REAL data sources (QA M2 F3). + * * Triggered by: PRAutoBlogger_Dossier_Page::render_page(). * Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map, * PRAutoBlogger_Run_Stage_State. @@ -55,6 +64,16 @@ class PRAutoBlogger_Dossier_Data_Assembler { 'stages' => array(), 'decisions' => array(), 'gen_log_index' => array(), + 'item_key' => '', + 'eligibility' => array( + 'ok' => false, + 'reason' => '', + ), + 'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ), + 'human_modified_any' => false, + 'stale_any' => false, + 'log_only_stages' => array(), + 'images' => array(), ); if ( $post_id <= 0 ) { @@ -82,11 +101,40 @@ class PRAutoBlogger_Dossier_Data_Assembler { return $base; } + $idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true ); + $item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : ''; + $base['item_key'] = $item_key; + $base['has_run'] = true; $base['run'] = $run; - $base['stages'] = $this->get_stage_rows( $run_id ); + $base['stages'] = $this->get_stage_rows( $run_id, $item_key ); $base['decisions'] = $this->get_decision_rows( $run_id ); - $base['gen_log_index'] = $this->log_query->get_by_run( $run_id ); + $base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id ); + $base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id ); + $base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run ); + + foreach ( $base['stages'] as $role_key => $stage_row ) { + $stage_name = (string) ( $stage_row['stage'] ?? '' ); + $rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage( + $stage_row, + $base['gen_log_index'][ $stage_name ] ?? array(), + $run_id, + $base['eligibility'] + ); + $base['stages'][ $role_key ]['rerun'] = $rerun; + $base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified']; + $base['stale_any'] = $base['stale_any'] || $rerun['stale']; + } + + // QA M2 F3: stages that exist only in generation_log (image_a/ + // image_b, llm_research, image_prompt_rewrite, ...) render from + // their real source instead of silently disappearing. + $run_stage_names = array(); + foreach ( $base['stages'] as $stage_row ) { + $run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true; + } + $base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) ); + $base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id ); return $base; } @@ -112,12 +160,14 @@ class PRAutoBlogger_Dossier_Data_Assembler { } /** - * Fetch all run_stages rows for a run, indexed by stage name. + * Fetch run_stages rows for a run, indexed by stage name — scoped to + * one item (+ run-level rows) when an item key is known. * - * @param string $run_id Pipeline run UUID. + * @param string $run_id Pipeline run UUID. + * @param string $item_key Item key ('' = no scoping, M2 behavior). * @return array> Stage rows keyed by stage name. */ - private function get_stage_rows( string $run_id ): array { + private function get_stage_rows( string $run_id, string $item_key = '' ): array { if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) { return array(); } @@ -126,11 +176,22 @@ class PRAutoBlogger_Dossier_Data_Assembler { return array(); } $table = PRAutoBlogger_Run_Stage_State::table_name(); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ), - ARRAY_A - ); + // v0.20.0: scope to THIS post's item + run-level rows. When the + // idea-hash meta is absent (pre-v0.18 posts) fall back to the + // unfiltered M2 behavior (single-item runs are unaffected). + if ( '' !== $item_key ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ), + ARRAY_A + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ), + ARRAY_A + ); + } $index = array(); foreach ( ( $rows ?? array() ) as $row ) { @@ -145,6 +206,29 @@ class PRAutoBlogger_Dossier_Data_Assembler { return $index; } + /** + * Drop gen_log rows linked to OTHER posts (multi-article runs share + * one run_id). Rows with no post linkage (NULL/0 — run-level work + * and pre-link entries) are kept. + * + * @param array>> $log_index Rows keyed by stage. + * @param int $post_id This dossier's post. + * @return array>> + */ + private function filter_log_for_post( array $log_index, int $post_id ): array { + $filtered = array(); + foreach ( $log_index as $stage => $rows ) { + foreach ( $rows as $row ) { + $row_post = (int) ( $row['post_id'] ?? 0 ); + if ( 0 !== $row_post && $row_post !== $post_id ) { + continue; + } + $filtered[ $stage ][] = $row; + } + } + return $filtered; + } + /** * Extract the text output from a run_stages meta_json blob. * Returns null when meta_json is NULL (pruned) or absent. diff --git a/includes/admin/class-dossier-image-data.php b/includes/admin/class-dossier-image-data.php new file mode 100644 index 0000000..185aba9 --- /dev/null +++ b/includes/admin/class-dossier-image-data.php @@ -0,0 +1,82 @@ + + */ + 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; + } +} diff --git a/includes/admin/class-dossier-page.php b/includes/admin/class-dossier-page.php index f970fa2..2dedbc9 100644 --- a/includes/admin/class-dossier-page.php +++ b/includes/admin/class-dossier-page.php @@ -119,6 +119,42 @@ class PRAutoBlogger_Dossier_Page { PRAUTOBLOGGER_VERSION, true ); + + // M3: edit + re-run layer (separate files; dossier.css is not grown). + wp_enqueue_style( + 'prautoblogger-dossier-edit', + PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css', + array( 'prautoblogger-dossier' ), + PRAUTOBLOGGER_VERSION + ); + wp_enqueue_script( + 'prautoblogger-dossier-edit', + PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js', + array(), + PRAUTOBLOGGER_VERSION, + true + ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config. + $post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0; + wp_localize_script( + 'prautoblogger-dossier-edit', + 'prabDossierEdit', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ), + 'postId' => $post_id, + // Settings read at point of use -- same poll setting as the board. + 'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ), + 'strings' => array( + 'saving' => __( 'Saving…', 'prautoblogger' ), + 'error' => __( 'Request failed — try again.', 'prautoblogger' ), + 'queued' => __( 'Queued…', 'prautoblogger' ), + 'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ), + 'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ), + 'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ), + ), + ) + ); } /** diff --git a/includes/admin/class-dossier-rerun-panel-data.php b/includes/admin/class-dossier-rerun-panel-data.php new file mode 100644 index 0000000..92eb677 --- /dev/null +++ b/includes/admin/class-dossier-rerun-panel-data.php @@ -0,0 +1,181 @@ +|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 $stage_row run_stages row. + * @param array> $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 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> $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; + } +} diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 0869050..9ebf6a9 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -179,6 +179,13 @@ class PRAutoBlogger { add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) ); add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) ); + // v0.20.0 (M3): dossier edit + re-run endpoints (validate + queue only). + $dossier_actions = new PRAutoBlogger_Dossier_Actions(); + add_action( 'wp_ajax_prautoblogger_dossier_save_input', array( $dossier_actions, 'on_save_input' ) ); + add_action( 'wp_ajax_prautoblogger_dossier_rerun_stage', array( $dossier_actions, 'on_rerun_stage' ) ); + add_action( 'wp_ajax_prautoblogger_dossier_rerun_from', array( $dossier_actions, 'on_rerun_from' ) ); + add_action( 'wp_ajax_prautoblogger_dossier_stage_status', array( $dossier_actions, 'on_stage_status' ) ); + $ideas = new PRAutoBlogger_Ideas_Browser(); add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) ); add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) ); diff --git a/includes/core/class-rerun-executor.php b/includes/core/class-rerun-executor.php index ce2411f..3ad8672 100644 --- a/includes/core/class-rerun-executor.php +++ b/includes/core/class-rerun-executor.php @@ -218,5 +218,4 @@ class PRAutoBlogger_Rerun_Executor { PRAutoBlogger_Generation_Lock::release(); } } - } diff --git a/includes/core/class-run-stage-state.php b/includes/core/class-run-stage-state.php index e29f76d..2826073 100644 --- a/includes/core/class-run-stage-state.php +++ b/includes/core/class-run-stage-state.php @@ -237,9 +237,35 @@ class PRAutoBlogger_Run_Stage_State { return is_array( $rows ) ? $rows : array(); } - /** Reset per-request caches (tests). */ /** Reset per-request caches (tests). */ + /** + * 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 $run_ids Run UUIDs. + * @return array 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( + "SELECT DISTINCT run_id FROM {$table} WHERE stale = 1 AND run_id IN ({$placeholders})", + $run_ids + ) + ); + return is_array( $rows ) ? array_map( 'strval', $rows ) : array(); + } + + /** Reset per-request caches (tests). */ public static function flush_cache(): void { self::$table_ok = null; } - } diff --git a/prautoblogger.php b/prautoblogger.php index e6546bc..1c5e8ce 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,8 @@ * Plugin Name: 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. - * Version: 0.19.4 +define( 'PRAUTOBLOGGER_VERSION', '0.20.0' ); +define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' ); * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +36,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.19.4' ); +define( 'PRAUTOBLOGGER_VERSION', '0.20.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/templates/admin/dossier-edit-panel.php b/templates/admin/dossier-edit-panel.php new file mode 100644 index 0000000..eb76eb4 --- /dev/null +++ b/templates/admin/dossier-edit-panel.php @@ -0,0 +1,109 @@ + '', + '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']; +?> + diff --git a/templates/admin/dossier-log-stage-section.php b/templates/admin/dossier-log-stage-section.php new file mode 100644 index 0000000..e6679b3 --- /dev/null +++ b/templates/admin/dossier-log-stage-section.php @@ -0,0 +1,118 @@ + +
+ +
+

+
+ + + + + + 0 ) : ?> + $ + + +
+
+ + +
+ +
+ <?php echo esc_attr( (string) $lo_image['role'] ); ?> +
+ + + + +
+
+ +
+ +

+ + +
+ + +
+ +
diff --git a/templates/admin/dossier-page.php b/templates/admin/dossier-page.php index 7b15c24..e526992 100644 --- a/templates/admin/dossier-page.php +++ b/templates/admin/dossier-page.php @@ -101,6 +101,13 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' ); + + > + + + > + +
0 ) : ?> @@ -142,6 +149,16 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' ); 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 ) ) : ?>
@@ -190,6 +207,8 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' );
+ +

diff --git a/templates/admin/dossier-sidebar-cards.php b/templates/admin/dossier-sidebar-cards.php new file mode 100644 index 0000000..6dd1e5e --- /dev/null +++ b/templates/admin/dossier-sidebar-cards.php @@ -0,0 +1,86 @@ + 0.0, + 'reserved' => 0.0, + 'ceiling' => 0.0, + 'warn' => false, +); +$sc_held = (float) $sc_spend['settled'] + (float) $sc_spend['reserved']; +?> +
+

+
+
+
$
+ 0 ) : ?> +
+
$
+ +
+
+ 0 ) : ?> + $ + + + +
+
+ +

+ +

+ +
+ +
+

+
+ + $sc_rows ) : ?> + + + + + + + +
+ + + + +
+
diff --git a/templates/admin/dossier-stage-section.php b/templates/admin/dossier-stage-section.php index 5b7113c..244e640 100644 --- a/templates/admin/dossier-stage-section.php +++ b/templates/admin/dossier-stage-section.php @@ -3,9 +3,10 @@ * Partial: single stage section for the Article Dossier. * * Variables injected by dossier-page.php: - * $stage_row (array) -- run_stages row incl. display_output + * $stage_row (array) -- run_stages row incl. display_output + ['rerun'] panel data (M3) * $stage_name (string) -- stage key * $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(). * request_json escaped via esc_html() -- treat as untrusted HTML. @@ -30,9 +31,14 @@ $s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $fi $s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—'; $has_raw_trace = ! empty( $log_rows ); $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 ); ?> -
@@ -46,6 +52,24 @@ $section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_ro + + > + + + > + + + 1 ) : ?> + + + + 0 ) : ?> $ @@ -123,4 +147,27 @@ $section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_ro
+ + + +
diff --git a/templates/admin/review-queue.php b/templates/admin/review-queue.php index 3d2cd95..287a80c 100644 --- a/templates/admin/review-queue.php +++ b/templates/admin/review-queue.php @@ -103,6 +103,18 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp + 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 ) ) ); + ?> @@ -137,6 +149,11 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
+ + + + +
diff --git a/tests/unit/Admin/BoardHumanModifiedFlagTest.php b/tests/unit/Admin/BoardHumanModifiedFlagTest.php new file mode 100644 index 0000000..2b8ebe0 --- /dev/null +++ b/tests/unit/Admin/BoardHumanModifiedFlagTest.php @@ -0,0 +1,120 @@ +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 ); + } +} diff --git a/tests/unit/Admin/DossierActionsRerunTest.php b/tests/unit/Admin/DossierActionsRerunTest.php new file mode 100644 index 0000000..66cd08b --- /dev/null +++ b/tests/unit/Admin/DossierActionsRerunTest.php @@ -0,0 +1,103 @@ +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'] ); + } +} diff --git a/tests/unit/Admin/DossierActionsSaveInputTest.php b/tests/unit/Admin/DossierActionsSaveInputTest.php new file mode 100644 index 0000000..d5e5e70 --- /dev/null +++ b/tests/unit/Admin/DossierActionsSaveInputTest.php @@ -0,0 +1,87 @@ +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. + } +} diff --git a/tests/unit/Admin/DossierActionsTestCase.php b/tests/unit/Admin/DossierActionsTestCase.php new file mode 100644 index 0000000..86ba309 --- /dev/null +++ b/tests/unit/Admin/DossierActionsTestCase.php @@ -0,0 +1,212 @@ +query(). + * + * @var string[] + */ + protected array $queries = array(); + + /** + * Rows captured from $wpdb->insert(). + * + * @var array + */ + protected array $inserted = array(); + + /** + * Cron events captured from wp_schedule_single_event(). + * + * @var 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', + ), + ) + ); + } +} diff --git a/tests/unit/Admin/DossierAssemblerRerunDataTest.php b/tests/unit/Admin/DossierAssemblerRerunDataTest.php new file mode 100644 index 0000000..0decff7 --- /dev/null +++ b/tests/unit/Admin/DossierAssemblerRerunDataTest.php @@ -0,0 +1,236 @@ +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%. + } +} diff --git a/tests/unit/Admin/DossierDataAssemblerTest.php b/tests/unit/Admin/DossierDataAssemblerTest.php index 82355d4..2320227 100644 --- a/tests/unit/Admin/DossierDataAssemblerTest.php +++ b/tests/unit/Admin/DossierDataAssemblerTest.php @@ -37,6 +37,14 @@ class DossierDataAssemblerTest extends BaseTestCase { // Stub PRAutoBlogger_Run_Stage_State::is_available() to return true. // Static method -- stubbed via wpdb table check returning the table name. \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. $this->wpdb->prefix = 'wp_'; diff --git a/tests/unit/Admin/DossierMenuRegistrationTestCase.php b/tests/unit/Admin/DossierMenuRegistrationTestCase.php index 7f06bcb..b665865 100644 --- a/tests/unit/Admin/DossierMenuRegistrationTestCase.php +++ b/tests/unit/Admin/DossierMenuRegistrationTestCase.php @@ -51,6 +51,11 @@ abstract class DossierMenuRegistrationTestCase extends BaseTestCase { $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( diff --git a/tests/unit/BaseTestCase.php b/tests/unit/BaseTestCase.php index bef09ff..0cdeb22 100644 --- a/tests/unit/BaseTestCase.php +++ b/tests/unit/BaseTestCase.php @@ -147,7 +147,7 @@ abstract class BaseTestCase extends TestCase { */ protected function create_mock_wpdb() { $wpdb = $this->getMockBuilder( \stdClass::class ) - ->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'update' ] ) + ->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'get_col', 'update' ] ) ->getMock(); $wpdb->prefix = 'wp_';