From 97ea798dbe04a3f0ad271a5fef64e3ec98329c5a Mon Sep 17 00:00:00 2001
From: ykulynych
Date: Fri, 13 Nov 2026 18:12:47 +0100
Subject: [PATCH 1/2] PDPS-3514: Fix sync timeouts and rate limiting on large
kbs
---
.distignore | 1 +
assets/css/admin.css | 86 +++++
assets/js/admin.js | 142 ++++++--
docker/docker-compose.scaling.yml | 63 ++++
docker/scripts/check-release.sh | 2 +-
docker/scripts/run-scaling.sh | 13 +
docker/scripts/setup-scaling.sh | 10 +
package.json | 3 +-
playwright.scaling.config.ts | 18 +
readme.txt | 8 +
src/Admin/AdminPage.php | 63 +++-
src/Api/ApiClient.php | 250 +++++--------
src/Indexing/ManualPreparation.php | 459 ++++++++++++++++++++++++
src/Indexing/ManualSync.php | 267 ++++----------
src/Indexing/PreparationStore.php | 295 +++++++++++++++
src/Indexing/Scheduler.php | 7 +-
templates/admin/indexation.php | 3 +-
templates/admin/manual-sync-modal.php | 5 +-
templates/admin/sync-banner.php | 26 ++
tests/phpunit/AdminPageTest.php | 58 ++-
tests/phpunit/ApiClientTest.php | 152 +++-----
tests/phpunit/ManualPreparationTest.php | 349 ++++++++++++++++++
tests/phpunit/ManualSyncTest.php | 344 ++++++++----------
tests/phpunit/bootstrap.php | 47 ++-
tests/scaling/fixtures/scaling.php | 294 +++++++++++++++
tests/scaling/sync.spec.ts | 216 +++++++++++
uninstall.php | 5 +
27 files changed, 2470 insertions(+), 716 deletions(-)
create mode 100644 docker/docker-compose.scaling.yml
create mode 100644 docker/scripts/run-scaling.sh
create mode 100644 docker/scripts/setup-scaling.sh
create mode 100644 playwright.scaling.config.ts
create mode 100644 src/Indexing/ManualPreparation.php
create mode 100644 src/Indexing/PreparationStore.php
create mode 100644 templates/admin/sync-banner.php
create mode 100644 tests/phpunit/ManualPreparationTest.php
create mode 100644 tests/scaling/fixtures/scaling.php
create mode 100644 tests/scaling/sync.spec.ts
diff --git a/.distignore b/.distignore
index ac1b7ed..418c1fc 100644
--- a/.distignore
+++ b/.distignore
@@ -8,6 +8,7 @@
/package-lock.json
/phpunit.xml.dist
/playwright.config.ts
+/playwright.scaling.config.ts
/README.md
/.DS_Store
/.phpunit.result.cache
diff --git a/assets/css/admin.css b/assets/css/admin.css
index c289f8b..d5f7fba 100644
--- a/assets/css/admin.css
+++ b/assets/css/admin.css
@@ -730,6 +730,16 @@ body.toplevel_page_progress-agentic-rag #wpcontent {
color: var(--progress-agentic-rag-color-danger-regular);
}
+.progress-agentic-rag__sync-cancel {
+ display: flex;
+ margin: 1rem 0 0 auto;
+ width: fit-content;
+}
+
+.progress-agentic-rag__sync-cancel[hidden] {
+ display: none;
+}
+
.progress-agentic-rag__button:disabled {
background: hsl(220, 18%, 92%);
border-color: hsl(220, 18%, 88%);
@@ -920,6 +930,82 @@ body.toplevel_page_progress-agentic-rag #wpcontent {
width: 0;
}
+.progress-agentic-rag__spinner,
+.progress-agentic-rag__button--busy::before {
+ animation: progress-agentic-rag-spin 800ms linear infinite;
+ border: 2px solid currentcolor;
+ border-radius: 50%;
+ border-right-color: transparent;
+ box-sizing: border-box;
+ flex: none;
+ height: 1em;
+ width: 1em;
+}
+
+.progress-agentic-rag__button--busy::before {
+ content: "";
+ margin-right: var(--progress-agentic-rag-space-075);
+}
+
+.progress-agentic-rag__sync-banner {
+ align-items: center;
+ background: var(--progress-agentic-rag-color-light-stronger);
+ border: 1px solid var(--progress-agentic-rag-surface-border);
+ border-left: 4px solid var(--progress-agentic-rag-color-primary-regular);
+ border-radius: var(--progress-agentic-rag-card-border-radius);
+ color: var(--progress-agentic-rag-color-primary-regular);
+ display: flex;
+ gap: var(--progress-agentic-rag-space-2);
+ margin-bottom: var(--progress-agentic-rag-space-2);
+ padding: var(--progress-agentic-rag-space-2);
+}
+
+.progress-agentic-rag__sync-banner[hidden] {
+ display: none;
+}
+
+.progress-agentic-rag__sync-banner--done {
+ border-left-color: var(--progress-agentic-rag-color-success-regular);
+ color: var(--progress-agentic-rag-color-success-regular);
+}
+
+.progress-agentic-rag__sync-banner--done .progress-agentic-rag__spinner {
+ display: none;
+}
+
+.progress-agentic-rag__sync-banner-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.progress-agentic-rag__sync-banner-text {
+ color: var(--progress-agentic-rag-color-dark-stronger);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--progress-agentic-rag-space-1);
+}
+
+.progress-agentic-rag__sync-banner-text span {
+ color: var(--progress-agentic-rag-color-neutral-regular);
+}
+
+.progress-agentic-rag__sync-banner-percent {
+ font-size: var(--progress-agentic-rag-font-size-display-s);
+}
+
+@keyframes progress-agentic-rag-spin {
+ to {
+ transform: rotate( 360deg );
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .progress-agentic-rag__spinner,
+ .progress-agentic-rag__button--busy::before {
+ animation-duration: 3s;
+ }
+}
+
.progress-agentic-rag__sync-stats {
display: grid;
gap: var(--progress-agentic-rag-space-1);
diff --git a/assets/js/admin.js b/assets/js/admin.js
index 842de8b..eccab1b 100644
--- a/assets/js/admin.js
+++ b/assets/js/admin.js
@@ -19,6 +19,9 @@
const mappingContainer = document.querySelector( '[data-progress-agentic-rag-mapping-container]' );
const addMappingButton = document.querySelector( '[data-progress-agentic-rag-add-mapping]' );
const modal = document.querySelector( '[data-progress-agentic-rag-sync-modal]' );
+ const syncBanner = document.querySelector( '[data-progress-agentic-rag-sync-banner]' );
+ const syncDetailsButton = document.querySelector( '[data-progress-agentic-rag-sync-details]' );
+ const cancelSyncButton = document.querySelector( '[data-progress-agentic-rag-sync-cancel]' );
const appearanceForm = document.querySelector( '[data-progress-agentic-rag-widget-appearance-form]' );
const appearancePreview = document.querySelector( '[data-progress-agentic-rag-widget-preview]' );
const defaultStartButtonText = config.strings && config.strings.sync ? config.strings.sync : startButton ? startButton.textContent : '';
@@ -85,6 +88,7 @@
let deletePollTimer = null;
let backgroundPollTimer = null;
let syncStatus = config.initialSyncStatus || {};
+ let syncRunObserved = false;
let deleteStatus = config.initialDeleteStatus || {};
let backgroundSyncStatus = config.initialBackgroundSyncStatus || {};
const mappingConfig = config.mapping || { taxonomies: {}, labelsets: [] };
@@ -153,6 +157,9 @@
const setModalMode = ( mode ) => {
const isDelete = mode === 'delete';
+ if ( cancelSyncButton ) {
+ cancelSyncButton.hidden = isDelete || ! syncIsRunning() || ! syncStatus.cancellable;
+ }
const closeButton = field( selectors.closeButton );
setText( selectors.label, isDelete ? config.strings.deleteModalLabel : config.strings.syncModalLabel );
@@ -236,6 +243,12 @@
};
const closeModal = () => {
+ // The counters on this page are rendered by PHP, so show the result of a run that finished.
+ if ( syncRunObserved && ! syncIsRunning() ) {
+ window.location.reload();
+ return;
+ }
+
modal.hidden = true;
document.body.classList.remove( 'progress-agentic-rag-sync-open' );
if ( pollTimer && ! syncIsRunning() ) {
@@ -246,11 +259,42 @@
window.clearTimeout( deletePollTimer );
deletePollTimer = null;
}
- startButton.disabled = deleteIsRunning() ? true : startButtonInitiallyDisabled;
+ if ( startButton ) {
+ startButton.disabled = deleteIsRunning() ? true : startButtonInitiallyDisabled;
+ }
+ };
+
+ const renderBanner = ( status ) => {
+ if ( ! syncBanner ) {
+ return;
+ }
+
+ const running = syncIsRunning();
+ const percent = Math.max( 0, Math.min( 100, statusNumber( status, 'percent' ) ) );
+ const bar = syncBanner.querySelector( '[data-progress-agentic-rag-banner-bar]' );
+ const set = ( selector, value ) => {
+ const element = syncBanner.querySelector( selector );
+ if ( element ) {
+ element.textContent = value;
+ }
+ };
+
+ // A finished run stays visible until the page is reloaded so its outcome is not lost.
+ syncBanner.hidden = ! running && ! syncRunObserved;
+ syncBanner.classList.toggle( 'progress-agentic-rag__sync-banner--done', ! running );
+ if ( bar ) {
+ bar.style.width = percent + '%';
+ }
+ set( '[data-progress-agentic-rag-banner-title]', running ? config.strings.running : config.strings.syncFinished );
+ set( '[data-progress-agentic-rag-banner-message]', status.message || '' );
+ set( '[data-progress-agentic-rag-banner-percent]', percent + '%' );
};
const renderStatus = ( status ) => {
syncStatus = status || {};
+ if ( cancelSyncButton ) {
+ cancelSyncButton.hidden = ! syncIsRunning() || ! status.cancellable;
+ }
if ( pollTimer ) {
window.clearTimeout( pollTimer );
pollTimer = null;
@@ -271,15 +315,22 @@
setText( selectors.current, status.current || '' );
if ( syncIsRunning() ) {
- startButton.textContent = runningButtonText( status );
- startButton.disabled = false;
+ syncRunObserved = true;
+ renderBanner( status );
+ if ( startButton ) {
+ startButton.textContent = runningButtonText( status );
+ startButton.disabled = false;
+ startButton.classList.add( 'progress-agentic-rag__button--busy' );
+ }
pollTimer = window.setTimeout( pollStatus, 2000 );
return;
}
- if ( status.status !== 'starting' ) {
+ renderBanner( status );
+ if ( startButton && status.status !== 'starting' ) {
startButton.textContent = defaultStartButtonText;
startButton.disabled = startButtonInitiallyDisabled;
+ startButton.classList.remove( 'progress-agentic-rag__button--busy' );
}
};
@@ -299,8 +350,8 @@
try {
renderStatus( await request( 'progress_agentic_rag_manual_sync_status' ) );
} catch ( error ) {
- startButton.disabled = false;
- renderError( error.message );
+ setText( selectors.message, config.strings.pollInterrupted );
+ pollTimer = window.setTimeout( pollStatus, 10000 );
}
};
@@ -332,7 +383,9 @@
if ( deleteIsRunning() ) {
deleteSyncedButton.textContent = deleteButtonText( status );
deleteSyncedButton.disabled = false;
- startButton.disabled = true;
+ if ( startButton ) {
+ startButton.disabled = true;
+ }
deletePollTimer = window.setTimeout( pollDeleteStatus, 2000 );
return;
}
@@ -348,7 +401,7 @@
if ( status.status !== 'starting' ) {
deleteSyncedButton.textContent = defaultDeleteButtonText;
deleteSyncedButton.disabled = deleteButtonInitiallyDisabled;
- if ( ! syncIsRunning() ) {
+ if ( startButton && ! syncIsRunning() ) {
startButton.disabled = startButtonInitiallyDisabled;
}
}
@@ -809,40 +862,65 @@
} );
}
- if ( startButton && modal ) {
- startButton.addEventListener( 'click', async () => {
- setModalMode( 'sync' );
- openModal();
-
- if ( syncIsRunning() ) {
+ if ( modal ) {
+ if ( cancelSyncButton ) {
+ cancelSyncButton.addEventListener( 'click', async () => {
+ cancelSyncButton.disabled = true;
try {
- renderStatus( await request( 'progress_agentic_rag_manual_sync_status' ) );
+ renderStatus( await request( 'progress_agentic_rag_manual_sync_cancel' ) );
} catch ( error ) {
- renderError( error.message );
+ window.alert( error.message );
+ } finally {
+ cancelSyncButton.disabled = false;
}
- return;
- }
+ } );
+ }
- const postTypes = selectedPostTypes();
- startButton.disabled = true;
+ const showSyncProgress = async () => {
+ setModalMode( 'sync' );
+ openModal();
try {
- renderStatus( {
- status: 'starting',
- total: 0,
- completed: 0,
- failed: 0,
- current: '',
- message: config.strings.starting,
- percent: 0,
- } );
- renderStatus( await request( 'progress_agentic_rag_manual_sync_start', { post_types: postTypes } ) );
+ renderStatus( await request( 'progress_agentic_rag_manual_sync_status' ) );
} catch ( error ) {
- startButton.disabled = false;
renderError( error.message );
}
- } );
+ };
+
+ if ( syncDetailsButton ) {
+ syncDetailsButton.addEventListener( 'click', showSyncProgress );
+ }
+
+ if ( startButton ) {
+ startButton.addEventListener( 'click', async () => {
+ if ( syncIsRunning() ) {
+ showSyncProgress();
+ return;
+ }
+
+ setModalMode( 'sync' );
+ openModal();
+
+ const postTypes = selectedPostTypes();
+ startButton.disabled = true;
+ try {
+ renderStatus( {
+ status: 'starting',
+ total: 0,
+ completed: 0,
+ failed: 0,
+ current: '',
+ message: config.strings.starting,
+ percent: 0,
+ } );
+ renderStatus( await request( 'progress_agentic_rag_manual_sync_start', { post_types: postTypes } ) );
+ } catch ( error ) {
+ startButton.disabled = false;
+ renderError( error.message );
+ }
+ } );
+ }
if ( syncIsRunning() ) {
renderStatus( syncStatus );
}
diff --git a/docker/docker-compose.scaling.yml b/docker/docker-compose.scaling.yml
new file mode 100644
index 0000000..bab79b7
--- /dev/null
+++ b/docker/docker-compose.scaling.yml
@@ -0,0 +1,63 @@
+name: progress-agentic-rag-scaling
+
+services:
+ db:
+ image: mysql:8.0
+ environment:
+ MYSQL_DATABASE: wordpress
+ MYSQL_USER: wordpress
+ MYSQL_PASSWORD: scaling-only
+ MYSQL_ROOT_PASSWORD: scaling-root-only
+ volumes:
+ - scaling-db:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
+ interval: 2s
+ timeout: 5s
+ retries: 60
+
+ wordpress:
+ image: wordpress:latest
+ depends_on:
+ db:
+ condition: service_healthy
+ environment: &wp-environment
+ WORDPRESS_DB_HOST: db
+ WORDPRESS_DB_USER: wordpress
+ WORDPRESS_DB_PASSWORD: scaling-only
+ WORDPRESS_DB_NAME: wordpress
+ WORDPRESS_CONFIG_EXTRA: |
+ define('DISABLE_WP_CRON', true);
+ define('PROGRESS_AGENTIC_RAG_SCALING_TESTS', true);
+ volumes: &wp-volumes
+ - scaling-wp:/var/www/html
+ - ..:/var/www/html/wp-content/plugins/progress-agentic-rag:ro
+ - ../tests/scaling/fixtures/scaling.php:/var/www/html/wp-content/mu-plugins/scaling.php:ro
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost/wp-login.php"]
+ interval: 2s
+ timeout: 5s
+ retries: 60
+
+ setup:
+ image: wordpress:cli
+ user: "0"
+ depends_on:
+ wordpress:
+ condition: service_healthy
+ environment: *wp-environment
+ volumes: *wp-volumes
+ entrypoint: ["sh", "/var/www/html/wp-content/plugins/progress-agentic-rag/docker/scripts/setup-scaling.sh"]
+
+ tests:
+ image: mcr.microsoft.com/playwright:v1.61.0-noble
+ working_dir: /plugin
+ environment:
+ WP_BASE_URL: http://wordpress
+ volumes:
+ - ..:/plugin
+ command: ["sh", "-c", "npx --no-install playwright test -c playwright.scaling.config.ts"]
+
+volumes:
+ scaling-db:
+ scaling-wp:
diff --git a/docker/scripts/check-release.sh b/docker/scripts/check-release.sh
index f049974..d45bbd4 100644
--- a/docker/scripts/check-release.sh
+++ b/docker/scripts/check-release.sh
@@ -11,7 +11,7 @@ fi
sh "$script_dir/check-release-metadata.sh"
-for path in .git .github .distignore .gitignore .DS_Store docker tests node_modules package.json package-lock.json phpunit.xml.dist playwright.config.ts README.md; do
+for path in .git .github .distignore .gitignore .DS_Store docker tests node_modules package.json package-lock.json phpunit.xml.dist playwright.config.ts playwright.scaling.config.ts README.md; do
if [ -e "$release_dir/$path" ]; then
echo "Release artifact includes dev-only path: $path"
exit 1
diff --git a/docker/scripts/run-scaling.sh b/docker/scripts/run-scaling.sh
new file mode 100644
index 0000000..915237b
--- /dev/null
+++ b/docker/scripts/run-scaling.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+set -eu
+
+cd "$(dirname "$0")/../.."
+compose_file=docker/docker-compose.scaling.yml
+
+npm ci --quiet --ignore-scripts
+
+# These services and volumes belong only to the isolated scaling project.
+trap 'docker compose -f "$compose_file" stop wordpress db >/dev/null' EXIT
+docker compose -f "$compose_file" up -d --wait db wordpress
+docker compose -f "$compose_file" run --rm setup
+docker compose -f "$compose_file" run --rm tests
diff --git a/docker/scripts/setup-scaling.sh b/docker/scripts/setup-scaling.sh
new file mode 100644
index 0000000..f44cfd5
--- /dev/null
+++ b/docker/scripts/setup-scaling.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+cd /var/www/html
+if ! wp core is-installed --allow-root; then
+ wp core install --allow-root --url=http://wordpress --title="RAG scaling tests" \
+ --admin_user=admin --admin_password=scaling-only --admin_email=scaling@example.test --skip-email
+fi
+wp plugin activate progress-agentic-rag --allow-root --url=http://wordpress
+wp option update progress_agentic_rag_e2e_key scaling-only --allow-root --url=http://wordpress
diff --git a/package.json b/package.json
index 8d353a6..571443d 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
- "test:e2e": "playwright test -c playwright.config.ts"
+ "test:e2e": "playwright test -c playwright.config.ts",
+ "test:scaling": "sh docker/scripts/run-scaling.sh"
},
"devDependencies": {
"@playwright/test": "1.61.0",
diff --git a/playwright.scaling.config.ts b/playwright.scaling.config.ts
new file mode 100644
index 0000000..15c86d5
--- /dev/null
+++ b/playwright.scaling.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/scaling',
+ testMatch: '**/*.spec.ts',
+ timeout: 120000,
+ expect: { timeout: 10000 },
+ retries: 0,
+ workers: 1,
+ reporter: [['list'], ['html', { outputFolder: 'build/scaling-report', open: 'never' }]],
+ outputDir: 'build/scaling-results',
+ use: {
+ baseURL: process.env.WP_BASE_URL || 'http://wordpress',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure'
+ },
+ projects: [{ name: 'scaling', use: { ...devices['Desktop Chrome'] } }]
+});
diff --git a/readme.txt b/readme.txt
index ed30e68..f4aba6e 100644
--- a/readme.txt
+++ b/readme.txt
@@ -74,6 +74,14 @@ value is passed as the second argument.
* Breaking: minimum PHP raised from 8.1 to 8.2. PHP 8.1 no longer receives security
fixes, including for the critical "php2shell" RCE vulnerabilities (CVE-2026-109618,
CVE-2026-109619). Update to PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later (8.3+ recommended).
+* Fix: Sync no longer times out (504) or hits the Progress API rate limit (429) on sites with many
+ resources. The resource list is now paginated correctly, the sync status poll is read-only, and
+ saving settings or clicking Sync starts a resumable background run instead of scanning the whole
+ knowledge base inside one web request. A conflict on an existing resource is resolved with a
+ single lookup by slug.
+* Improved: A progress banner on every plugin tab shows the sync state. The button is now labelled
+ "Sync", shows a spinner while a run is active, and opens details with a Cancel option.
+* Improved: Rate-limit responses now show "Rate limit reached; retry shortly" instead of a generic lookup error.
* Dev: PHP lint and PHPUnit in CI now run on PHP 8.3.
= 1.0.3 =
diff --git a/src/Admin/AdminPage.php b/src/Admin/AdminPage.php
index c7299b1..0a0fa07 100644
--- a/src/Admin/AdminPage.php
+++ b/src/Admin/AdminPage.php
@@ -43,6 +43,7 @@ public function register(): void {
add_action( 'admin_post_progress_agentic_rag_save_widget_appearance', [ $this, 'save_widget_appearance_settings' ] );
add_action( 'wp_ajax_progress_agentic_rag_manual_sync_start', [ $this, 'start_manual_sync' ] );
add_action( 'wp_ajax_progress_agentic_rag_manual_sync_status', [ $this, 'manual_sync_status' ] );
+ add_action( 'wp_ajax_progress_agentic_rag_manual_sync_cancel', [ $this, 'cancel_manual_sync' ] );
add_action( 'wp_ajax_progress_agentic_rag_background_sync_status', [ $this, 'background_sync_status' ] );
add_action( 'wp_ajax_progress_agentic_rag_delete_synced_resources', [ $this, 'delete_synced_resources' ] );
add_action( 'wp_ajax_progress_agentic_rag_delete_synced_resources_status', [ $this, 'delete_synced_resources_status' ] );
@@ -115,17 +116,19 @@ public function enqueue_assets( string $hook_suffix ): void {
'deleteCurrentLabel' => __( 'Current resource', 'progress-agentic-rag-connector' ),
'deleteRunning' => __( 'Delete in progress', 'progress-agentic-rag-connector' ),
'deleting' => __( 'Preparing synced resource deletion.', 'progress-agentic-rag-connector' ),
- 'failed' => __( 'Manual sync could not start.', 'progress-agentic-rag-connector' ),
+ 'failed' => __( 'Sync could not start.', 'progress-agentic-rag-connector' ),
'running' => __( 'Sync in progress', 'progress-agentic-rag-connector' ),
- 'syncModalLabel' => __( 'Manual sync', 'progress-agentic-rag-connector' ),
+ 'syncModalLabel' => __( 'Sync', 'progress-agentic-rag-connector' ),
'syncModalTitle' => __( 'Syncing selected content', 'progress-agentic-rag-connector' ),
'syncTotalLabel' => __( 'Total entities', 'progress-agentic-rag-connector' ),
'syncDoneLabel' => __( 'Synced', 'progress-agentic-rag-connector' ),
'syncCurrentLabel' => __( 'Current entity', 'progress-agentic-rag-connector' ),
- 'sync' => __( 'Sync manually', 'progress-agentic-rag-connector' ),
+ 'sync' => __( 'Sync', 'progress-agentic-rag-connector' ),
+ 'syncFinished' => __( 'Sync finished', 'progress-agentic-rag-connector' ),
'reprocessFailed' => __( 'Label reprocessing could not start.', 'progress-agentic-rag-connector' ),
- 'starting' => __( 'Preparing manual sync.', 'progress-agentic-rag-connector' ),
- 'complete' => __( 'Manual sync complete.', 'progress-agentic-rag-connector' ),
+ 'starting' => __( 'Preparing sync.', 'progress-agentic-rag-connector' ),
+ 'pollInterrupted' => __( 'Connection interrupted. Background sync continues; reconnecting…', 'progress-agentic-rag-connector' ),
+ 'complete' => __( 'Sync complete.', 'progress-agentic-rag-connector' ),
'mappingSelectLabelset' => __( 'Select a labelset', 'progress-agentic-rag-connector' ),
'mappingRemove' => __( 'Remove', 'progress-agentic-rag-connector' ),
'mappingLabelset' => __( 'Labelset', 'progress-agentic-rag-connector' ),
@@ -164,7 +167,10 @@ public function render(): void {
$token_status_label = $this->settings->get_api_is_reachable() ? __( 'Saved', 'progress-agentic-rag-connector' ) : __( 'Stored, unverified', 'progress-agentic-rag-connector' );
}
+ $manual_sync_status = $this->manual_sync->status();
+
require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/page-open.php';
+ require PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/sync-banner.php';
$this->render_tabs( $active_tab );
if ( self::TAB_CONNECTION === $active_tab ) {
$this->render_connection_settings();
@@ -182,6 +188,7 @@ public function render(): void {
$this->render_indexation();
}
$this->render_updated_notice();
+ require PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/manual-sync-modal.php';
require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/page-close.php';
}
@@ -200,10 +207,11 @@ public function save_connection_settings(): void {
$this->settings->update_connection_settings( $zone, $kbid, $account_id, $token );
$connected = $this->validate_connection_settings();
$this->settings->set_api_is_reachable( $connected );
+ $sync_started = false;
if ( $connected ) {
- $this->manual_sync->ensure_automatic_sync( true );
+ $sync_started = $this->start_sync_after_save();
}
- $this->redirect_to_tab( self::TAB_CONNECTION, $connected ? 'connected' : 'failed' );
+ $this->redirect_to_tab( self::TAB_CONNECTION, $connected ? 'connected' : 'failed', $sync_started );
}
public function save_indexation_settings(): void {
@@ -230,8 +238,7 @@ public function save_indexation_settings(): void {
$this->settings->update_indexable_post_types( $selected );
$this->manual_sync->cancel_post_type_sync( array_values( array_diff( $previous, $selected ) ) );
- $this->manual_sync->ensure_automatic_sync( true );
- $this->redirect_to_tab( self::TAB_INDEXATION );
+ $this->redirect_to_tab( self::TAB_INDEXATION, '', $this->start_sync_after_save() );
}
public function save_taxonomy_labeling_settings(): void {
@@ -244,8 +251,7 @@ public function save_taxonomy_labeling_settings(): void {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked above; SettingsRepository sanitizes the nested map.
$raw_taxonomy_map = isset( $_POST[ SettingsRepository::OPTION_TAXONOMY_LABEL_MAP ] ) && is_array( $_POST[ SettingsRepository::OPTION_TAXONOMY_LABEL_MAP ] ) ? wp_unslash( $_POST[ SettingsRepository::OPTION_TAXONOMY_LABEL_MAP ] ) : [];
$this->settings->update_taxonomy_label_map( $raw_taxonomy_map );
- $this->manual_sync->ensure_automatic_sync( true );
- $this->redirect_to_tab( self::TAB_TAXONOMY_LABELING );
+ $this->redirect_to_tab( self::TAB_TAXONOMY_LABELING, '', $this->start_sync_after_save() );
}
public function save_widget_appearance_settings(): void {
@@ -304,6 +310,18 @@ public function manual_sync_status(): void {
wp_send_json_success( $this->manual_sync->status() );
}
+ public function cancel_manual_sync(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( [ 'message' => __( 'You do not have permission to cancel sync.', 'progress-agentic-rag-connector' ) ], 403 );
+ }
+ check_ajax_referer( 'progress_agentic_rag_manual_sync' );
+ $result = $this->manual_sync->cancel_manual_sync();
+ if ( is_wp_error( $result ) ) {
+ wp_send_json_error( [ 'message' => sanitize_text_field( $result->get_error_message() ) ], 400 );
+ }
+ wp_send_json_success( $result );
+ }
+
public function background_sync_status(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => __( 'You do not have permission to view automatic sync status.', 'progress-agentic-rag-connector' ) ], 403 );
@@ -311,7 +329,7 @@ public function background_sync_status(): void {
check_ajax_referer( 'progress_agentic_rag_manual_sync' );
- wp_send_json_success( $this->manual_sync->ensure_automatic_sync() );
+ wp_send_json_success( $this->manual_sync->background_sync_status() );
}
public function delete_synced_resources(): void {
@@ -528,11 +546,13 @@ private function render_updated_notice(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Connection status is read-only redirect state.
$connection_status = isset( $_GET['connection-status'] ) ? sanitize_key( wp_unslash( $_GET['connection-status'] ) ) : '';
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice flag is read-only redirect state.
+ $sync_note = isset( $_GET['sync-started'] ) ? ' ' . __( 'A content sync has started in the background. You can follow its progress at the top of this page.', 'progress-agentic-rag-connector' ) : '';
if ( 'connected' === $connection_status ) {
$notice_type = 'success';
$notice_role = 'status';
- $notice_message = __( 'Connection settings saved. Progress Agentic RAG validated the connection successfully.', 'progress-agentic-rag-connector' );
+ $notice_message = __( 'Connection settings saved. Progress Agentic RAG validated the connection successfully.', 'progress-agentic-rag-connector' ) . $sync_note;
require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/notice.php';
return;
}
@@ -547,7 +567,7 @@ private function render_updated_notice(): void {
$notice_type = 'success';
$notice_role = 'status';
- $notice_message = __( 'Settings saved.', 'progress-agentic-rag-connector' );
+ $notice_message = __( 'Settings saved.', 'progress-agentic-rag-connector' ) . $sync_note;
require_once PROGRESS_AGENTIC_RAG_PATH . 'templates/admin/notice.php';
}
@@ -603,7 +623,7 @@ private function render_indexation(): void {
$delete_status = $this->manual_sync->delete_status();
$delete_running = 'running' === ( $delete_status['status'] ?? '' );
$manual_sync_disabled = 'running' !== ( $manual_sync_status['status'] ?? '' ) && ( ! $api_connected || ! $scheduler_available || $delete_running );
- $manual_sync_button_text = __( 'Sync manually', 'progress-agentic-rag-connector' );
+ $manual_sync_button_text = __( 'Sync', 'progress-agentic-rag-connector' );
if ( 'running' === ( $manual_sync_status['status'] ?? '' ) ) {
$manual_sync_button_text = sprintf(
/* translators: 1: processed entity count, 2: total entity count, 3: completion percent. */
@@ -1176,7 +1196,14 @@ private function tab_url( string $tab ): string {
);
}
- private function redirect_to_tab( string $tab, string $connection_status = '' ): void {
+ private function start_sync_after_save(): bool {
+ $was_running = 'running' === ( $this->manual_sync->status()['status'] ?? '' );
+ $this->manual_sync->ensure_automatic_sync( true );
+
+ return ! $was_running && 'running' === ( $this->manual_sync->status()['status'] ?? '' );
+ }
+
+ private function redirect_to_tab( string $tab, string $connection_status = '', bool $sync_started = false ): void {
$args = [
'progress-agentic-rag-updated' => 'true',
];
@@ -1185,6 +1212,10 @@ private function redirect_to_tab( string $tab, string $connection_status = '' ):
$args['connection-status'] = $connection_status;
}
+ if ( $sync_started ) {
+ $args['sync-started'] = '1';
+ }
+
wp_safe_redirect(
add_query_arg(
$args,
diff --git a/src/Api/ApiClient.php b/src/Api/ApiClient.php
index 35264ba..87a8eb0 100644
--- a/src/Api/ApiClient.php
+++ b/src/Api/ApiClient.php
@@ -362,97 +362,67 @@ public function get_synced_resources(): array {
}
/**
- * @param list $post_ids
+ * Fetch exactly one page for checkpointed background preparation.
+ *
+ * @return array{resources:list,last:bool,fingerprint:string}|WP_Error
*/
- public function recover_existing_resources( array $post_ids ): int|WP_Error {
- $slugs = [];
-
- foreach ( $post_ids as $post_id ) {
- $post_id = (int) $post_id;
- if ( $post_id > 0 ) {
- $slugs[ (string) $post_id ] = $post_id;
- }
- }
-
- if ( empty( $slugs ) ) {
- return 0;
+ public function resource_page( int $page ): array|WP_Error {
+ $endpoint = $this->endpoint();
+ $token = $this->settings->get_string( SettingsRepository::OPTION_TOKEN );
+ if ( '' === $endpoint || '' === $token ) {
+ return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] );
}
-
- $resources = $this->find_resources_by_slugs( array_keys( $slugs ) );
- if ( is_wp_error( $resources ) ) {
- return $resources;
+ $response = wp_remote_request( $endpoint . 'resources?page=' . $page . '&size=250', [
+ 'method' => 'GET',
+ 'headers' => [ 'X-NUCLIA-SERVICEACCOUNT' => 'Bearer ' . $token ],
+ 'timeout' => 30,
+ ] );
+ if ( is_wp_error( $response ) ) {
+ return $response;
}
-
- $recovered = 0;
- foreach ( $resources as $slug => $resource ) {
- if ( '' === $resource['rid'] ) {
- continue;
- }
-
- $this->upsert_index( $slugs[ $slug ], $resource['rid'], $resource['seqid'] );
- $recovered++;
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
+ $retry_after = (string) wp_remote_retrieve_header( $response, 'retry-after' );
+ $delay = ctype_digit( $retry_after ) ? (int) $retry_after : max( 0, (int) strtotime( $retry_after ) - time() );
+ return new WP_Error(
+ 429 === $code ? 'progress_agentic_rag_rate_limited' : 'progress_agentic_rag_resource_page_failed',
+ 429 === $code
+ ? __( 'Progress Agentic RAG rate limit reached. Preparation will retry after a delay.', 'progress-agentic-rag-connector' )
+ : $this->response_error_message( $response, __( 'Could not retrieve a resource page.', 'progress-agentic-rag-connector' ) ),
+ [ 'retryable' => 429 === $code || $code >= 500, 'retry_after' => max( 30, min( 3600, $delay ) ) ]
+ );
}
-
- return $recovered;
- }
-
- /**
- * @return array{checked:int,removed:int,updated:int}|WP_Error
- */
- public function reconcile_synced_resources(): array|WP_Error {
- $mapped = [];
-
- foreach ( $this->get_synced_resources() as $resource ) {
- $post_id = isset( $resource->post_id ) ? (int) $resource->post_id : 0;
- $rid = isset( $resource->nuclia_rid ) ? sanitize_text_field( (string) $resource->nuclia_rid ) : '';
- if ( $post_id <= 0 || '' === $rid ) {
- continue;
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+ $pagination = is_array( $body['pagination'] ?? null ) ? $body['pagination'] : [];
+ if ( ! is_array( $body['resources'] ?? null ) || ! array_is_list( $body['resources'] )
+ || ( isset( $body['pagination'] ) && ! is_array( $body['pagination'] ) )
+ || ( array_key_exists( 'last', $pagination ) && ! is_bool( $pagination['last'] ) )
+ || ( isset( $pagination['page'] ) && ( ( ! is_int( $pagination['page'] ) && ! is_string( $pagination['page'] ) )
+ || ! ctype_digit( (string) $pagination['page'] ) || $page !== (int) $pagination['page'] ) ) ) {
+ return new WP_Error( 'progress_agentic_rag_invalid_resource_page', __( 'The API returned an invalid resource page. Existing mappings were preserved.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] );
+ }
+ $resources = [];
+ foreach ( $body['resources'] as $resource ) {
+ $rid = is_array( $resource ) ? ( $resource['id'] ?? $resource['uuid'] ?? '' ) : '';
+ $slug = $resource['slug'] ?? '';
+ $seqid = $resource['last_seqid'] ?? $resource['seqid'] ?? '';
+ if ( ! is_string( $rid ) || '' === $rid || ! is_scalar( $slug ) || ! is_scalar( $seqid ) ) {
+ return new WP_Error( 'progress_agentic_rag_invalid_resource_page', __( 'The API returned an invalid resource. Existing mappings were preserved.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] );
}
-
- $mapped[ (string) $post_id ] = [
- 'post_id' => $post_id,
- 'rid' => $rid,
- 'seqid' => isset( $resource->nuclia_seqid ) ? sanitize_text_field( (string) $resource->nuclia_seqid ) : '',
- ];
- }
-
- if ( empty( $mapped ) ) {
- return [
- 'checked' => 0,
- 'removed' => 0,
- 'updated' => 0,
+ $resources[] = [
+ 'slug' => (string) $slug,
+ 'rid' => sanitize_text_field( $rid ),
+ 'seqid' => sanitize_text_field( (string) $seqid ),
];
}
-
- $resources = $this->find_resources_by_slugs( array_keys( $mapped ) );
- if ( is_wp_error( $resources ) ) {
- return $resources;
- }
-
- $removed = 0;
- $updated = 0;
- foreach ( $mapped as $slug => $local ) {
- $resource = $resources[ $slug ] ?? [
- 'rid' => '',
- 'seqid' => '',
- ];
-
- if ( '' === $resource['rid'] ) {
- $this->delete_index( $local['post_id'] );
- $removed++;
- continue;
- }
-
- if ( $resource['rid'] !== $local['rid'] || ( '' !== $resource['seqid'] && $resource['seqid'] !== $local['seqid'] ) ) {
- $this->upsert_index( $local['post_id'], $resource['rid'], '' !== $resource['seqid'] ? $resource['seqid'] : $local['seqid'] );
- $updated++;
- }
+ $last = array_key_exists( 'last', $pagination ) ? (bool) $pagination['last'] : count( $resources ) < 250;
+ if ( empty( $resources ) && ! $last ) {
+ return new WP_Error( 'progress_agentic_rag_invalid_resource_page', __( 'The API returned an empty non-final page. Existing mappings were preserved.', 'progress-agentic-rag-connector' ), [ 'retryable' => false ] );
}
-
return [
- 'checked' => count( $mapped ),
- 'removed' => $removed,
- 'updated' => $updated,
+ 'resources' => $resources,
+ 'last' => $last,
+ 'fingerprint' => hash( 'sha256', wp_json_encode( array_column( $resources, 'rid' ) ) ),
];
}
@@ -474,94 +444,58 @@ private function endpoint(): string {
/**
* @return array{rid:string,seqid:string}|WP_Error
*/
- private function find_resource_by_slug( string $slug ): array|WP_Error {
- if ( '' === $slug ) {
- return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ) );
- }
-
- $resources = $this->find_resources_by_slugs( [ $slug ] );
- if ( is_wp_error( $resources ) ) {
- return $resources;
- }
-
- return $resources[ $slug ] ?? [
- 'rid' => '',
- 'seqid' => '',
- ];
- }
-
- /**
- * @param list $slugs
- *
- * @return array|WP_Error
- */
- private function find_resources_by_slugs( array $slugs ): array|WP_Error {
+ public function find_resource_by_slug( string $slug ): array|WP_Error {
$endpoint = $this->endpoint();
$token = $this->settings->get_string( SettingsRepository::OPTION_TOKEN );
- $lookup = [];
-
- foreach ( $slugs as $slug ) {
- $slug = trim( (string) $slug );
- if ( '' !== $slug ) {
- $lookup[ $slug ] = true;
- }
+ if ( '' === $slug || '' === $endpoint || '' === $token ) {
+ return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ) );
}
- if ( empty( $lookup ) ) {
- return [];
- }
+ $response = wp_remote_request(
+ $endpoint . 'slug/' . rawurlencode( $slug ),
+ [
+ 'method' => 'GET',
+ 'headers' => [
+ 'X-NUCLIA-SERVICEACCOUNT' => 'Bearer ' . $token,
+ ],
+ 'timeout' => 30,
+ ]
+ );
- if ( '' === $endpoint || '' === $token ) {
- return new WP_Error( 'progress_agentic_rag_missing_connection', __( 'Progress Agentic RAG connection settings are incomplete.', 'progress-agentic-rag-connector' ) );
+ if ( is_wp_error( $response ) ) {
+ return $response;
}
- $page_size = 250;
- $offset = 0;
- $matches = [];
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ if ( 404 === $code ) {
+ return [
+ 'rid' => '',
+ 'seqid' => '',
+ ];
+ }
- do {
- $response = wp_remote_request(
- $endpoint . 'resources?from=' . $offset . '&size=' . $page_size,
- [
- 'method' => 'GET',
- 'headers' => [
- 'X-NUCLIA-SERVICEACCOUNT' => 'Bearer ' . $token,
- ],
- 'timeout' => 30,
- ]
+ if ( 200 !== $code ) {
+ return new WP_Error(
+ 429 === $code ? 'progress_agentic_rag_rate_limited' : 'progress_agentic_rag_existing_resource_lookup_failed',
+ 429 === $code
+ ? __( 'Progress Agentic RAG rate limit reached. Please retry shortly.', 'progress-agentic-rag-connector' )
+ : $this->response_error_message( $response, __( 'Progress Agentic RAG could not look up the existing resource.', 'progress-agentic-rag-connector' ) )
);
+ }
- if ( is_wp_error( $response ) ) {
- return $response;
- }
-
- if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
- return new WP_Error( 'progress_agentic_rag_existing_resource_lookup_failed', $this->response_error_message( $response, __( 'Progress Agentic RAG could not look up the existing resource.', 'progress-agentic-rag-connector' ) ) );
- }
-
- $api_response = json_decode( wp_remote_retrieve_body( $response ), true );
- $resources = is_array( $api_response['resources'] ?? null ) ? $api_response['resources'] : [];
-
- foreach ( $resources as $resource ) {
- $slug = is_array( $resource ) ? (string) ( $resource['slug'] ?? '' ) : '';
- if ( ! isset( $lookup[ $slug ] ) ) {
- continue;
- }
-
- $matches[ $slug ] = [
- 'rid' => sanitize_text_field( (string) ( $resource['id'] ?? $resource['uuid'] ?? '' ) ),
- 'seqid' => sanitize_text_field( (string) ( $resource['last_seqid'] ?? $resource['seqid'] ?? '' ) ),
- ];
-
- if ( count( $matches ) === count( $lookup ) ) {
- break 2;
- }
- }
+ $resource = json_decode( wp_remote_retrieve_body( $response ), true );
+ $resource = is_array( $resource ) ? $resource : [];
+ $rid = sanitize_text_field( (string) ( $resource['id'] ?? $resource['uuid'] ?? '' ) );
- $offset += $page_size;
- } while ( count( $resources ) === $page_size );
+ // Only a 404 means "missing"; a 200 without an id (e.g. a proxy error page) must not look like one.
+ if ( '' === $rid ) {
+ return new WP_Error( 'progress_agentic_rag_existing_resource_lookup_failed', __( 'Progress Agentic RAG returned an unexpected response while looking up the existing resource.', 'progress-agentic-rag-connector' ) );
+ }
- return $matches;
+ return [
+ 'rid' => $rid,
+ 'seqid' => sanitize_text_field( (string) ( $resource['last_seqid'] ?? $resource['seqid'] ?? '' ) ),
+ ];
}
/**
diff --git a/src/Indexing/ManualPreparation.php b/src/Indexing/ManualPreparation.php
new file mode 100644
index 0000000..821c8e5
--- /dev/null
+++ b/src/Indexing/ManualPreparation.php
@@ -0,0 +1,459 @@
+yield_runner;
+ }
+
+ public function begin( array $types, string $trigger = 'manual' ): array|WP_Error {
+ if ( ! $this->store->lock() ) {
+ return new WP_Error( 'progress_agentic_rag_sync_busy', __( 'Sync is busy. Please retry shortly.', 'progress-agentic-rag-connector' ) );
+ }
+ try {
+ $current = $this->state();
+ if ( 'running' === ( $current['status'] ?? '' ) ) {
+ return $current;
+ }
+ // A persisted recovery event closes the crash window before the first action is queued.
+ if ( ! $this->arm_recovery() ) {
+ return new WP_Error( 'progress_agentic_rag_recovery_unavailable', __( 'Could not schedule sync recovery. Check WordPress cron.', 'progress-agentic-rag-connector' ) );
+ }
+ $state = [
+ 'id' => wp_generate_uuid4(), 'status' => 'running', 'orchestrated' => true,
+ 'phase' => 'mappings', 'step' => 0, 'cursor' => 0, 'page' => 0, 'retry_count' => 0,
+ 'post_types' => $types, 'source' => $this->source(), 'trigger' => $trigger,
+ 'total' => 0, 'completed' => 0, 'failed' => 0, 'recovered' => 0,
+ 'current' => __( 'Waiting for background preparation.', 'progress-agentic-rag-connector' ),
+ 'message' => __( 'Preparing sync…', 'progress-agentic-rag-connector' ),
+ 'started_at' => time(), 'updated_at' => time(),
+ ];
+ $this->save( $state );
+ if ( ! $this->schedule( $state, time() ) ) {
+ $this->fail( $state, __( 'Could not queue sync preparation. Please retry.', 'progress-agentic-rag-connector' ) );
+ return new WP_Error( 'progress_agentic_rag_schedule_failed', $state['message'] );
+ }
+ return $state;
+ } finally {
+ $this->store->unlock();
+ }
+ }
+
+ public function process( string $run, int $step ): void {
+ if ( ! $this->store->lock() ) {
+ $this->scheduler->schedule_single_action( time() + 30, self::HOOK, [ 'sync_id' => $run, 'step' => $step ], self::GROUP );
+ return;
+ }
+ try {
+ $state = $this->state();
+ if ( ! $this->active( $state, $run ) || empty( $state['phase'] ) || $step !== (int) $state['step'] ) {
+ return;
+ }
+ $this->yield_runner = true;
+ if ( (int) ( $state['next_attempt'] ?? 0 ) > time() ) {
+ $this->schedule( $state, $state['next_attempt'] );
+ return;
+ }
+ if ( $state['source'] !== $this->source() || ! $this->settings->get_api_is_reachable() ) {
+ $this->fail( $state, __( 'Connection settings changed during preparation. Start a new sync.', 'progress-agentic-rag-connector' ) );
+ return;
+ }
+ $this->arm_recovery();
+ $types = array_values( array_intersect( $state['post_types'], array_keys( array_filter( $this->settings->get_indexable_post_types() ) ) ) );
+ if ( empty( $types ) ) {
+ $this->fail( $state, __( 'Selected content types are no longer enabled. Start a new sync.', 'progress-agentic-rag-connector' ) );
+ return;
+ }
+ switch ( $state['phase'] ) {
+ case 'mappings':
+ if ( empty( $state['table_ready'] ) ) {
+ $this->store->initialize();
+ $state += $this->store->bounds();
+ $state['table_ready'] = true;
+ $this->save( $state );
+ }
+ $chunk = $this->store->snapshot_mappings( $run, $state['cursor'], $state['mapping_limit'] );
+ $state['cursor'] = $chunk['cursor'];
+ if ( $chunk['done'] ) {
+ $state['phase'] = 'candidates';
+ $state['cursor'] = 0;
+ }
+ break;
+ case 'candidates':
+ $chunk = $this->store->snapshot_candidates( $run, $state['cursor'], $state['post_limit'], $types );
+ $state['cursor'] = $chunk['cursor'];
+ if ( $chunk['done'] ) {
+ $state['phase'] = 'scan';
+ $state['cursor'] = 0;
+ }
+ break;
+ case 'scan':
+ $remote = $this->remote( $run, $step, fn () => $this->api->resource_page( $state['page'] ) );
+ if ( null === $remote ) {
+ return;
+ }
+ [ $state, $page ] = $remote;
+ if ( is_wp_error( $page ) ) {
+ $this->retry( $state, $page );
+ return;
+ }
+ if ( ! empty( $page['resources'] ) && ( $state['fingerprint'] ?? null ) === $page['fingerprint'] ) {
+ $this->fail( $state, __( 'Resource lookup stopped because the API repeated a page. Existing mappings were preserved.', 'progress-agentic-rag-connector' ) );
+ return;
+ }
+ $this->store->record_page( $run, $page['resources'] );
+ $state['fingerprint'] = $page['fingerprint'];
+ $state['page']++;
+ if ( $page['last'] ) {
+ $state['phase'] = 'verify';
+ $state['cursor'] = 0;
+ }
+ break;
+ case 'verify':
+ // The listing is paginated by offset without a snapshot, so a resource can be skipped while the KB changes.
+ // Confirm each mapping the scan did not see before apply may delete it.
+ $rows = $this->store->unseen( $run, $state['cursor'], self::VERIFY_BATCH );
+ $remote = empty( $rows ) ? [ $state, [ 'found' => [], 'error' => null ] ] : $this->remote( $run, $step, function () use ( $rows ) {
+ $found = [];
+ foreach ( $rows as $row ) {
+ $resource = $this->api->find_resource_by_slug( (string) $row->post_id );
+ if ( is_wp_error( $resource ) ) {
+ return [ 'found' => $found, 'error' => $resource ];
+ }
+ $found[ (int) $row->post_id ] = $resource;
+ }
+ return [ 'found' => $found, 'error' => null ];
+ } );
+ if ( null === $remote ) {
+ return;
+ }
+ [ $state, $verified ] = $remote;
+ foreach ( $verified['found'] as $post_id => $resource ) {
+ if ( '' !== $resource['rid'] ) {
+ $this->store->record_page( $run, [ [ 'slug' => (string) $post_id, 'rid' => $resource['rid'], 'seqid' => $resource['seqid'] ] ] );
+ }
+ $state['cursor'] = $post_id;
+ }
+ if ( null !== $verified['error'] ) {
+ $this->retry( $state, $verified['error'] );
+ return;
+ }
+ if ( count( $rows ) < self::VERIFY_BATCH ) {
+ $state['phase'] = 'apply';
+ $state['cursor'] = 0;
+ }
+ break;
+ case 'apply':
+ $chunk = $this->store->apply_page( $run, $state['cursor'], $types );
+ $state['cursor'] = $chunk['cursor'];
+ if ( $chunk['done'] ) {
+ $state['phase'] = 'queue';
+ $state['cursor'] = 0;
+ }
+ break;
+ case 'queue':
+ $rows = $this->store->candidates( $run, $state['cursor'], $types );
+ foreach ( $rows as $offset => $row ) {
+ if ( ! $this->schedule_item( $run, (int) $row->post_id, $row->post_type, time() + ( ( $state['total'] + $offset ) * 2 ) ) ) {
+ throw new \RuntimeException( __( 'Could not queue a resource sync.', 'progress-agentic-rag-connector' ) );
+ }
+ $this->store->mark_queued( $run, (int) $row->post_id );
+ $state['cursor'] = (int) $row->post_id;
+ }
+ $state = array_replace( $state, $this->store->stats( $run ) );
+ if ( count( $rows ) < 100 ) {
+ $state['phase'] = '';
+ $state['current'] = __( 'Waiting for the first entity.', 'progress-agentic-rag-connector' );
+ $state['message'] = __( 'Preparation complete. Resources are queued for sync.', 'progress-agentic-rag-connector' );
+ if ( $state['completed'] + $state['failed'] >= $state['total'] ) {
+ $state['status'] = 'complete';
+ $state['message'] = 0 === $state['total']
+ ? __( 'Everything selected is already synced.', 'progress-agentic-rag-connector' )
+ : __( 'Sync complete.', 'progress-agentic-rag-connector' );
+ if ( 0 === $state['total'] ) {
+ $state['current'] = __( 'No eligible entities to sync.', 'progress-agentic-rag-connector' );
+ if ( $state['recovered'] > 0 ) {
+ $state['message'] = sprintf(
+ /* translators: %d is the number of recovered upstream resources. */
+ _n( 'Recovered %d existing upstream resource. Everything selected is already synced.', 'Recovered %d existing upstream resources. Everything selected is already synced.', $state['recovered'], 'progress-agentic-rag-connector' ),
+ $state['recovered']
+ );
+ }
+ }
+ $this->record_terminal( $state );
+ }
+ }
+ break;
+ }
+ $state['retry_count'] = 0;
+ $state['next_attempt'] = 0;
+ $state['step']++;
+ $state['updated_at'] = time();
+ if ( ! empty( $state['phase'] ) ) {
+ $state['current'] = sprintf(
+ /* translators: %d is the number of KB pages scanned. */
+ __( 'Preparing sync: %d KB pages checked.', 'progress-agentic-rag-connector' ), $state['page']
+ );
+ $state['message'] = __( 'Preparing sync…', 'progress-agentic-rag-connector' );
+ }
+ $this->save( $state );
+ if ( ! empty( $state['phase'] ) && ! $this->schedule( $state, time() + 2 ) ) {
+ throw new \RuntimeException( __( 'Could not queue the next preparation step.', 'progress-agentic-rag-connector' ) );
+ }
+ } catch ( \Throwable $error ) {
+ $state = $this->state();
+ if ( $this->active( $state, $run ) ) {
+ $this->retry( $state, new WP_Error( 'progress_agentic_rag_preparation_failed', sanitize_text_field( $error->getMessage() ) ) );
+ }
+ } finally {
+ $this->store->unlock();
+ }
+ }
+
+ public function process_item( int $id, string $type, string $run, callable $sync, string $label = '' ): void {
+ if ( ! $this->store->lock() ) {
+ $this->scheduler->schedule_single_action( time() + 30, self::POST_HOOK, [ 'post_id' => $id, 'post_type' => $type, 'sync_id' => $run ], self::GROUP );
+ return;
+ }
+ try {
+ $state = $this->state();
+ if ( ! $this->active( $state, $run ) || ( ! empty( $state['phase'] ) && 'queue' !== $state['phase'] ) ) {
+ return;
+ }
+ $outcome = $this->store->outcome( $run, $id );
+ if ( null === $outcome ) {
+ return;
+ }
+ $result = null;
+ // An item that already finished only refreshes the counters, which repairs a lost final update.
+ if ( '' === $outcome ) {
+ if ( $state['source'] !== $this->source() ) {
+ $this->fail( $state, __( 'Connection settings changed. Start a new sync.', 'progress-agentic-rag-connector' ) );
+ return;
+ }
+ if ( $this->store->begin_item( $run, $id ) ) {
+ if ( '' !== $label ) {
+ // Written while locked: saving the state after unlocking could undo a concurrent cancel.
+ $state['current'] = $label;
+ $state['updated_at'] = time();
+ $this->save( $state );
+ }
+ // The upload is slow, so do not hold the lock (and block cancelling) while it runs.
+ $this->store->unlock();
+ try {
+ $result = $sync();
+ $this->store->finish_item( $run, $id, ! is_wp_error( $result ) );
+ } finally {
+ $relocked = $this->store->lock( self::RELOCK_WAIT );
+ }
+ if ( ! $relocked ) {
+ $this->scheduler->schedule_single_action( time() + 30, self::POST_HOOK, [ 'post_id' => $id, 'post_type' => $type, 'sync_id' => $run ], self::GROUP );
+ return;
+ }
+ $state = $this->state();
+ if ( ! $this->active( $state, $run ) ) {
+ return;
+ }
+ } else {
+ $result = new WP_Error( 'progress_agentic_rag_item_interrupted', __( 'Resource sync was repeatedly interrupted. Retry this item after checking background scheduling.', 'progress-agentic-rag-connector' ) );
+ $this->settings->add_failed_sync_item( $id, $type, get_the_title( $id ), $result->get_error_message(), 'manual' );
+ $this->store->finish_item( $run, $id, false );
+ }
+ }
+ $state = array_replace( $state, $this->store->stats( $run ) );
+ if ( is_wp_error( $result ) ) {
+ $state['current'] = sanitize_text_field( $result->get_error_message() );
+ }
+ if ( empty( $state['phase'] ) && $state['completed'] + $state['failed'] >= $state['total'] ) {
+ $state['status'] = 'complete';
+ $state['message'] = __( 'Sync complete.', 'progress-agentic-rag-connector' );
+ $this->record_terminal( $state );
+ }
+ $state['updated_at'] = time();
+ $this->save( $state );
+ } finally {
+ $this->store->unlock();
+ }
+ }
+
+ public function recover(): void {
+ $state = $this->state();
+ if ( empty( $state['orchestrated'] ) || 'running' !== ( $state['status'] ?? '' ) ) {
+ return;
+ }
+ $this->arm_recovery();
+ if ( ! $this->store->lock() ) {
+ return;
+ }
+ try {
+ $state = $this->state();
+ if ( ! $this->active( $state, (string) ( $state['id'] ?? '' ) ) ) {
+ return;
+ }
+ if ( ! empty( $state['phase'] ) ) {
+ $args = [ 'sync_id' => $state['id'], 'step' => $state['step'] ];
+ if ( ! $this->scheduler->has_action( self::HOOK, $args, self::GROUP ) ) {
+ $this->retry( $state, new WP_Error( 'progress_agentic_rag_preparation_interrupted', __( 'Preparation was interrupted. Resuming the saved checkpoint.', 'progress-agentic-rag-connector' ) ) );
+ }
+ } elseif ( ! empty( $state['table_ready'] ) ) {
+ foreach ( $this->store->unfinished( $state['id'] ) as $row ) {
+ $this->schedule_item( $state['id'], (int) $row->post_id, $row->post_type, time() );
+ }
+ }
+ } finally {
+ $this->store->unlock();
+ }
+ }
+
+ public function cancel(): array|WP_Error {
+ if ( ! $this->store->lock( self::RELOCK_WAIT ) ) {
+ return new WP_Error( 'progress_agentic_rag_sync_busy', __( 'A sync step is finishing. Please retry cancellation shortly.', 'progress-agentic-rag-connector' ) );
+ }
+ try {
+ $state = $this->state();
+ if ( empty( $state['orchestrated'] ) || 'running' !== ( $state['status'] ?? '' ) ) {
+ return $state;
+ }
+ $state['status'] = 'cancelled';
+ $state['phase'] = '';
+ $state['message'] = __( 'Sync cancelled. Completed work was retained.', 'progress-agentic-rag-connector' );
+ $this->record_terminal( $state );
+ $this->save( $state );
+ $this->scheduler->unschedule_all_actions( self::HOOK, null, '' );
+ $this->scheduler->unschedule_all_actions( self::POST_HOOK, null, '' );
+ return $state;
+ } finally {
+ $this->store->unlock();
+ }
+ }
+
+ // Runs slow remote work without the lock so a cancel is never blocked behind it. Returns the fresh state and the
+ // result, or null when the step is no longer current.
+ private function remote( string $run, int $step, callable $work ): ?array {
+ $this->store->unlock();
+ try {
+ $result = $work();
+ } finally {
+ $relocked = $this->store->lock( self::RELOCK_WAIT );
+ }
+ if ( ! $relocked ) {
+ $this->scheduler->schedule_single_action( time() + 30, self::HOOK, [ 'sync_id' => $run, 'step' => $step ], self::GROUP );
+ return null;
+ }
+ $state = $this->state();
+ if ( ! $this->active( $state, $run ) || $step !== (int) $state['step'] ) {
+ return null;
+ }
+ return [ $state, $result ];
+ }
+
+ private function retry( array $state, WP_Error $error ): void {
+ $data = $error->get_error_data();
+ $data = is_array( $data ) ? $data : [];
+ $state['retry_count'] = (int) ( $state['retry_count'] ?? 0 ) + 1;
+ if ( false === ( $data['retryable'] ?? true ) || $state['retry_count'] > 3 ) {
+ $this->fail( $state, sanitize_text_field( $error->get_error_message() ) );
+ return;
+ }
+ $delay = max( 30 * ( 2 ** ( $state['retry_count'] - 1 ) ), (int) ( $data['retry_after'] ?? 0 ) );
+ $state['next_attempt'] = time() + min( 3600, $delay );
+ $state['updated_at'] = time();
+ $state['message'] = sanitize_text_field( $error->get_error_message() );
+ $state['current'] = __( 'Waiting to retry the saved preparation step.', 'progress-agentic-rag-connector' );
+ $this->save( $state );
+ $this->schedule( $state, $state['next_attempt'] );
+ }
+
+ private function fail( array &$state, string $message ): void {
+ $state['status'] = 'failed';
+ $state['message'] = $message;
+ $state['updated_at'] = time();
+ $this->record_terminal( $state );
+ $this->save( $state );
+ }
+
+ private function schedule( array $state, int $at ): bool {
+ return $this->scheduler->schedule_single_action( $at, self::HOOK, [ 'sync_id' => $state['id'], 'step' => $state['step'] ], self::GROUP );
+ }
+
+ private function schedule_item( string $run, int $id, string $type, int $at ): bool {
+ $args = [ 'post_id' => $id, 'post_type' => $type, 'sync_id' => $run ];
+ return $this->scheduler->has_action( self::POST_HOOK, $args, self::GROUP )
+ || $this->scheduler->schedule_single_action( $at, self::POST_HOOK, $args, self::GROUP );
+ }
+
+ private function arm_recovery(): bool {
+ return (bool) wp_next_scheduled( self::RECOVERY_HOOK )
+ || (bool) wp_schedule_single_event( time() + 120, self::RECOVERY_HOOK );
+ }
+
+ private function active( array $state, string $run ): bool {
+ return ! empty( $state['orchestrated'] ) && $run === ( $state['id'] ?? '' ) && 'running' === ( $state['status'] ?? '' );
+ }
+
+ private function state(): array {
+ $state = get_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, [] );
+ return is_array( $state ) ? $state : [];
+ }
+
+ private function save( array $state ): void {
+ update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state, false );
+ }
+
+ private function source(): string {
+ return hash( 'sha256', $this->settings->get_string( SettingsRepository::OPTION_ZONE ) . "\0"
+ . $this->settings->get_string( SettingsRepository::OPTION_KBID ) . "\0" . $this->settings->get_string( SettingsRepository::OPTION_TOKEN ) );
+ }
+
+ private function record_terminal( array &$state ): void {
+ if ( ! empty( $state['history_recorded'] ) ) {
+ return;
+ }
+ $this->settings->add_sync_history_entry( [
+ 'id' => $state['id'], 'type' => 'automatic' === ( $state['trigger'] ?? '' ) ? 'automatic_sync' : 'manual_sync',
+ 'status' => 'complete' === $state['status'] && $state['failed'] > 0 ? 'failed' : $state['status'],
+ 'total' => $state['total'], 'completed' => $state['completed'], 'failed' => $state['failed'],
+ 'message' => $state['message'], 'current' => $state['current'],
+ 'started_at' => $state['started_at'], 'finished_at' => time(),
+ ] );
+ $state['history_recorded'] = true;
+ if ( ! empty( $state['table_ready'] ) ) {
+ $this->store->purge( $state['id'] );
+ }
+ }
+}
diff --git a/src/Indexing/ManualSync.php b/src/Indexing/ManualSync.php
index 12d9861..32c35be 100644
--- a/src/Indexing/ManualSync.php
+++ b/src/Indexing/ManualSync.php
@@ -28,14 +28,17 @@ final class ManualSync {
private readonly SettingsRepository $settings;
private readonly ApiClient $api_client;
private readonly Scheduler $scheduler;
+ private readonly ManualPreparation $preparation;
- public function __construct( SettingsRepository $settings, ApiClient $api_client, ?Scheduler $scheduler = null ) {
+ public function __construct( SettingsRepository $settings, ApiClient $api_client, ?Scheduler $scheduler = null, ?PreparationStore $preparation_store = null ) {
$this->settings = $settings;
$this->api_client = $api_client;
$this->scheduler = $scheduler ?? new Scheduler();
+ $this->preparation = new ManualPreparation( $settings, $api_client, $this->scheduler, $preparation_store ?? new PreparationStore() );
}
public function register(): void {
+ $this->preparation->register();
add_action( self::HOOK_PROCESS_SINGLE, [ $this, 'process_single_post' ], 10, 3 );
add_action( self::HOOK_BACKGROUND_SYNC, [ $this, 'process_background_post' ], 10, 3 );
add_action( self::HOOK_BACKGROUND_DELETE, [ $this, 'process_background_delete' ], 10, 3 );
@@ -59,7 +62,7 @@ public function start( array $post_types ): array|WP_Error {
}
if ( 'running' === ( $this->delete_state()['status'] ?? '' ) ) {
- return new WP_Error( 'progress_agentic_rag_delete_running', __( 'Wait for synced resource deletion to finish before starting a manual sync.', 'progress-agentic-rag-connector' ) );
+ return new WP_Error( 'progress_agentic_rag_delete_running', __( 'Wait for synced resource deletion to finish before starting a sync.', 'progress-agentic-rag-connector' ) );
}
if ( ! $this->scheduler_available() ) {
@@ -75,80 +78,13 @@ public function start( array $post_types ): array|WP_Error {
return new WP_Error( 'progress_agentic_rag_no_post_types', __( 'Select at least one content type to sync.', 'progress-agentic-rag-connector' ) );
}
- $reconcile = $this->api_client->reconcile_synced_resources();
- if ( is_wp_error( $reconcile ) ) {
- return $reconcile;
- }
-
- $entities = $this->unindexed_entities( $post_types );
- $recovered = 0;
- if ( ! empty( $entities ) ) {
- $post_ids = [];
- foreach ( $entities as $entity ) {
- $post_ids[] = $entity['post_id'];
- }
-
- $recovery = $this->api_client->recover_existing_resources( $post_ids );
- if ( is_wp_error( $recovery ) ) {
- return $recovery;
- }
-
- $recovered = $recovery;
- if ( $recovered > 0 ) {
- $entities = $this->unindexed_entities( $post_types );
- }
- }
-
- $sync_id = wp_generate_uuid4();
- $total = count( $entities );
- $message = 0 === $total ? __( 'Everything selected is already synced.', 'progress-agentic-rag-connector' ) : sprintf(
- /* translators: %d is the number of entities queued for manual sync. */
- _n( 'Sync in progress: 0 of %d processed.', 'Sync in progress: 0 of %d processed.', $total, 'progress-agentic-rag-connector' ),
- $total
- );
-
- if ( $recovered > 0 ) {
- $message = 0 === $total ? sprintf(
- /* translators: %d is the number of upstream resources recovered into the local sync table. */
- _n( 'Recovered %d existing upstream resource. Everything selected is already synced.', 'Recovered %d existing upstream resources. Everything selected is already synced.', $recovered, 'progress-agentic-rag-connector' ),
- $recovered
- ) : sprintf(
- /* translators: 1: recovered upstream resource count, 2: number of entities queued for manual sync. */
- _n( 'Recovered %1$d existing upstream resource. Sync in progress: 0 of %2$d processed.', 'Recovered %1$d existing upstream resources. Sync in progress: 0 of %2$d processed.', $recovered, 'progress-agentic-rag-connector' ),
- $recovered,
- $total
- );
- }
-
- $state = [
- 'id' => $sync_id,
- 'status' => 0 === $total ? 'complete' : 'running',
- 'total' => $total,
- 'completed' => 0,
- 'failed' => 0,
- 'recovered' => $recovered,
- 'current' => 0 === $total ? __( 'No eligible entities to sync.', 'progress-agentic-rag-connector' ) : __( 'Waiting for the first entity.', 'progress-agentic-rag-connector' ),
- 'message' => $message,
- 'started_at' => time(),
- 'updated_at' => time(),
- ];
-
- update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state );
-
- foreach ( $entities as $offset => $entity ) {
- $this->schedule_single_action(
- time() + ( $offset * 2 ),
- self::HOOK_PROCESS_SINGLE,
- [
- 'post_id' => $entity['post_id'],
- 'post_type' => $entity['post_type'],
- 'sync_id' => $sync_id,
- ],
- self::GROUP
- );
- }
+ $result = $this->preparation->begin( $post_types );
+ return is_wp_error( $result ) ? $result : $this->status();
+ }
- return $this->status();
+ public function cancel_manual_sync(): array|WP_Error {
+ $result = $this->preparation->cancel();
+ return is_wp_error( $result ) ? $result : $this->status();
}
/**
@@ -161,9 +97,9 @@ public function status(): array {
$done = min( $total, $done );
$recovered = (int) ( $state['recovered'] ?? 0 );
- if ( $total > 0 && $done >= $total && 'running' === ( $state['status'] ?? '' ) ) {
+ if ( empty( $state['orchestrated'] ) && $total > 0 && $done >= $total && 'running' === ( $state['status'] ?? '' ) ) {
$state['status'] = 'complete';
- $state['message'] = __( 'Manual sync complete.', 'progress-agentic-rag-connector' );
+ $state['message'] = __( 'Sync complete.', 'progress-agentic-rag-connector' );
$state['updated_at'] = time();
$state = $this->record_history( $state, 'manual_sync', (int) ( $state['completed'] ?? 0 ) );
update_option( SettingsRepository::OPTION_MANUAL_SYNC_STATE, $state );
@@ -172,7 +108,8 @@ public function status(): array {
$current = (string) ( $state['current'] ?? __( 'Waiting to sync.', 'progress-agentic-rag-connector' ) );
$message = (string) ( $state['message'] ?? '' );
- if ( 'running' === ( $state['status'] ?? '' ) ) {
+ $preparing = 'running' === ( $state['status'] ?? '' ) && ! empty( $state['phase'] );
+ if ( 'running' === ( $state['status'] ?? '' ) && ! $preparing ) {
$message = sprintf(
/* translators: 1: processed entity count, 2: total entity count. */
__( 'Sync in progress: %1$d of %2$d processed.', 'progress-agentic-rag-connector' ),
@@ -194,10 +131,16 @@ public function status(): array {
$current = 0 === $done ? __( 'Waiting for the first entity.', 'progress-agentic-rag-connector' ) : __( 'Waiting for the next entity.', 'progress-agentic-rag-connector' );
}
}
+ if ( ! empty( $state['orchestrated'] ) && 'running' === ( $state['status'] ?? '' )
+ && time() - (int) ( $state['updated_at'] ?? time() ) > 300 && (int) ( $state['next_attempt'] ?? 0 ) <= time() ) {
+ $message .= ' ' . __( 'Waiting for background scheduling. Check WordPress cron if progress does not resume.', 'progress-agentic-rag-connector' );
+ }
return [
'id' => (string) ( $state['id'] ?? '' ),
'status' => (string) ( $state['status'] ?? 'idle' ),
+ 'preparing' => $preparing,
+ 'cancellable' => ! empty( $state['orchestrated'] ) && 'running' === ( $state['status'] ?? '' ),
'total' => $total,
'completed' => (int) ( $state['completed'] ?? 0 ),
'failed' => (int) ( $state['failed'] ?? 0 ),
@@ -205,7 +148,7 @@ public function status(): array {
'processed' => $done,
'current' => $current,
'message' => $message,
- 'percent' => $total > 0 ? min( 100, (int) floor( ( $done / $total ) * 100 ) ) : 100,
+ 'percent' => $preparing ? 0 : ( $total > 0 ? min( 100, (int) floor( ( $done / $total ) * 100 ) ) : ( in_array( $state['status'] ?? '', [ 'running', 'failed' ], true ) ? 0 : 100 ) ),
];
}
@@ -314,7 +257,12 @@ public function background_sync_status(): array {
$completed = (int) ( $state['completed'] ?? 0 );
$failed = (int) ( $state['failed'] ?? 0 );
$background_id = (string) ( $state['id'] ?? '' );
- if ( '' !== $background_id ) {
+ $pending = $this->count_background_actions( $this->action_status( 'STATUS_PENDING', 'pending' ) );
+ $running = $this->count_background_actions( $this->action_status( 'STATUS_RUNNING', 'in-progress' ) );
+
+ // Counters are kept by the jobs themselves. Only when the queue is idle but the run is still open can a counter
+ // update have been lost, so that is the only time the finished jobs are counted from the queue.
+ if ( '' !== $background_id && 'running' === ( $state['status'] ?? '' ) && 0 === $pending && 0 === $running ) {
$action_completed = $this->count_background_actions_for_id( $this->action_status( 'STATUS_COMPLETE', 'complete' ), $background_id );
$action_failed = $this->count_background_actions_for_id( $this->action_status( 'STATUS_FAILED', 'failed' ), $background_id );
$completed = max( $completed, $action_completed );
@@ -327,10 +275,16 @@ public function background_sync_status(): array {
}
}
$processed = min( $total, $completed + $failed );
- $pending = $this->count_background_actions( $this->action_status( 'STATUS_PENDING', 'pending' ) );
- $running = $this->count_background_actions( $this->action_status( 'STATUS_RUNNING', 'in-progress' ) );
$action_failed = $this->count_background_actions( $this->action_status( 'STATUS_FAILED', 'failed' ) );
+ // Queued jobs that no longer exist (for example purged) can never finish, so stop reporting the run as active.
+ $updated_at = (int) ( $state['updated_at'] ?? 0 );
+ if ( 'running' === ( $state['status'] ?? '' ) && $processed < $total && 0 === $pending && 0 === $running && $updated_at > 0 && time() - $updated_at > 15 * MINUTE_IN_SECONDS ) {
+ $failed += $total - $processed;
+ $processed = $total;
+ $state['failed'] = $failed;
+ }
+
if ( $total > 0 && $processed >= $total && 'running' === ( $state['status'] ?? '' ) ) {
$state['status'] = 'complete';
$state['message'] = $failed > 0
@@ -373,7 +327,8 @@ public function background_sync_status(): array {
}
/**
- * Queue unsynced selected content for automatic background sync.
+ * Start a background preparation run for unsynced selected content.
+ * No remote calls happen in the caller's request; the run reconciles with the KB in scheduled jobs.
*
* @return array
*/
@@ -396,34 +351,14 @@ public function ensure_automatic_sync( bool $retry_failed = false ): array {
return $this->background_sync_status();
}
- $reconcile = $this->api_client->reconcile_synced_resources();
- if ( is_wp_error( $reconcile ) ) {
- return $this->background_sync_status();
- }
-
- $entities = $this->unindexed_entities( $post_types );
- if ( ! empty( $entities ) ) {
- $post_ids = [];
- foreach ( $entities as $entity ) {
- $post_ids[] = $entity['post_id'];
- }
-
- $recovery = $this->api_client->recover_existing_resources( $post_ids );
- if ( ! is_wp_error( $recovery ) && $recovery > 0 ) {
- $entities = $this->unindexed_entities( $post_types );
- }
- }
-
$state = $this->background_state();
if ( ! $retry_failed && 'complete' === ( $state['status'] ?? '' ) && (int) ( $state['failed'] ?? 0 ) > 0 && ! empty( $this->settings->get_failed_sync_items() ) ) {
return $status;
}
- $scheduled = $this->schedule_background_entities( $entities );
- $status = $this->background_sync_status();
- $status['scheduled'] = $scheduled;
+ $result = $this->preparation->begin( $post_types, 'automatic' );
- return $status;
+ return is_wp_error( $result ) ? $status : $this->status();
}
/**
@@ -436,7 +371,7 @@ public function delete_synced_resources(): array|WP_Error {
}
if ( 'running' === ( $this->state()['status'] ?? '' ) ) {
- return new WP_Error( 'progress_agentic_rag_manual_sync_running', __( 'Wait for manual sync to finish before deleting synced resources.', 'progress-agentic-rag-connector' ) );
+ return new WP_Error( 'progress_agentic_rag_manual_sync_running', __( 'Wait for the sync to finish before deleting synced resources.', 'progress-agentic-rag-connector' ) );
}
if ( ! $this->scheduler_available() ) {
@@ -508,7 +443,9 @@ public function delete_status(): array {
$deleted = (int) ( $state['deleted'] ?? 0 );
$failed = (int) ( $state['failed'] ?? 0 );
$delete_id = (string) ( $state['id'] ?? '' );
- if ( '' !== $delete_id ) {
+ $delete_open = $this->scheduler->count_actions( self::HOOK_DELETE_RESOURCE, self::GROUP_DELETE, $this->action_status( 'STATUS_PENDING', 'pending' ) )
+ + $this->scheduler->count_actions( self::HOOK_DELETE_RESOURCE, self::GROUP_DELETE, $this->action_status( 'STATUS_RUNNING', 'in-progress' ) );
+ if ( '' !== $delete_id && 'running' === ( $state['status'] ?? '' ) && 0 === $delete_open ) {
$action_deleted = $this->count_delete_actions_for_id( $this->action_status( 'STATUS_COMPLETE', 'complete' ), $delete_id );
$action_failed = $this->count_delete_actions_for_id( $this->action_status( 'STATUS_FAILED', 'failed' ), $delete_id );
$deleted = max( $deleted, $action_deleted );
@@ -669,10 +606,13 @@ public function cancel_post_type_sync( array $post_types ): void {
$manual_cancelled = 0;
$background_cancelled = 0;
$sync_id = (string) ( $this->state()['id'] ?? '' );
+ $orchestrated = ! empty( $this->state()['orchestrated'] );
$background_id = (string) ( $this->background_state()['id'] ?? '' );
foreach ( $post_types as $post_type ) {
- $manual_cancelled += $this->cancel_pending_post_type_actions( self::HOOK_PROCESS_SINGLE, self::GROUP, $post_type, 'sync_id', $sync_id );
+ if ( ! $orchestrated ) {
+ $manual_cancelled += $this->cancel_pending_post_type_actions( self::HOOK_PROCESS_SINGLE, self::GROUP, $post_type, 'sync_id', $sync_id );
+ }
$background_cancelled += $this->cancel_pending_post_type_actions( self::HOOK_BACKGROUND_SYNC, self::GROUP_BACKGROUND, $post_type, 'background_id', $background_id );
}
@@ -699,6 +639,28 @@ public function label_reprocess_status(): array {
public function process_single_post( int $post_id, string $post_type, string $sync_id ): void {
$state = $this->state();
+ if ( ! empty( $state['orchestrated'] ) ) {
+ $entity = get_post( $post_id );
+ $label = $entity instanceof WP_Post ? $this->entity_label( $entity ) : '';
+ $this->preparation->process_item( $post_id, $post_type, $sync_id, function () use ( $post_id, $post_type ): bool|WP_Error {
+ $post = get_post( $post_id );
+ if ( ! $post instanceof WP_Post ) {
+ return new WP_Error( 'progress_agentic_rag_missing_entity', __( 'Missing entity.', 'progress-agentic-rag-connector' ) );
+ }
+ if ( ! $this->is_selected_post_type( $post_type ) || ! $this->is_indexable_post( $post, $post_type ) || $this->get_resource_id( $post_id ) ) {
+ $this->settings->remove_failed_sync_item( $post_id, $post_type );
+ return true;
+ }
+ $result = $this->api_client->index_post( $post );
+ if ( is_wp_error( $result ) ) {
+ $this->settings->add_failed_sync_item( $post_id, $post_type, $this->entity_label( $post ), $this->error_message( $result ), 'manual' );
+ return $result;
+ }
+ $this->settings->remove_failed_sync_item( $post_id, $post_type );
+ return true;
+ }, $label );
+ return;
+ }
if ( $sync_id !== ( $state['id'] ?? '' ) ) {
return;
}
@@ -924,95 +886,6 @@ public function process_background_delete( int $post_id, string $rid, string $ba
$this->mark_background_completed( $background_id );
}
- /**
- * @param list $post_types
- *
- * @return list
- */
- private function unindexed_entities( array $post_types ): array {
- global $wpdb;
-
- $entities = [];
- $sync_table_name = $this->sync_table_name();
-
- foreach ( $post_types as $post_type ) {
- $post_status = 'attachment' === $post_type ? 'inherit' : 'publish';
- $limit = 500;
- $offset = 0;
-
- do {
- // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Reads plugin-owned sync table; values are prepared and table names are escaped.
- $results = $wpdb->get_results(
- $wpdb->prepare(
- "SELECT p.ID FROM {$wpdb->posts} AS p
- LEFT JOIN {$sync_table_name} AS idx ON ( p.ID = idx.post_id )
- WHERE idx.post_id IS NULL
- AND p.post_type = %s
- AND p.post_status = %s
- AND p.post_password = %s
- LIMIT %d OFFSET %d",
- $post_type,
- $post_status,
- '',
- $limit,
- $offset
- )
- );
- // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
-
- foreach ( $results as $result ) {
- $entities[] = [
- 'post_id' => (int) $result->ID,
- 'post_type' => $post_type,
- ];
- }
-
- $offset += $limit;
- } while ( count( $results ) === $limit );
- }
-
- return $entities;
- }
-
- /**
- * @param list $entities
- */
- private function schedule_background_entities( array $entities ): int {
- $scheduled = 0;
-
- foreach ( $entities as $entity ) {
- $post_id = (int) $entity['post_id'];
- $post_type = (string) $entity['post_type'];
-
- if ( $this->is_background_sync_scheduled( $post_id, $post_type ) ) {
- continue;
- }
-
- $post = get_post( $post_id );
- $label = $post instanceof WP_Post ? $this->entity_label( $post ) : sprintf(
- /* translators: %d is the WordPress post ID for an entity being automatically synced. */
- __( 'Entity #%d', 'progress-agentic-rag-connector' ),
- $post_id
- );
- $background_id = $this->queue_background_sync( $label );
-
- $this->schedule_single_action(
- time() + ( $scheduled * 2 ),
- self::HOOK_BACKGROUND_SYNC,
- [
- 'post_id' => $post_id,
- 'post_type' => $post_type,
- 'background_id' => $background_id,
- ],
- self::GROUP_BACKGROUND
- );
-
- $scheduled++;
- }
-
- return $scheduled;
- }
-
private function is_background_sync_scheduled( int $post_id, string $post_type ): bool {
foreach ( [ $this->action_status( 'STATUS_PENDING', 'pending' ), $this->action_status( 'STATUS_RUNNING', 'in-progress' ) ] as $status ) {
foreach ( $this->scheduler->scheduled_actions( self::HOOK_BACKGROUND_SYNC, self::GROUP_BACKGROUND, $status ) as $action ) {
diff --git a/src/Indexing/PreparationStore.php b/src/Indexing/PreparationStore.php
new file mode 100644
index 0000000..1bdb5de
--- /dev/null
+++ b/src/Indexing/PreparationStore.php
@@ -0,0 +1,295 @@
+prefix );
+ $locked = '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $name, max( 0, $wait ) ) );
+ if ( $locked && function_exists( 'wp_cache_delete' ) ) {
+ wp_cache_delete( SettingsRepository::OPTION_MANUAL_SYNC_STATE, 'options' );
+ }
+ return $locked;
+ }
+
+ public function unlock(): void {
+ global $wpdb;
+ $name = 'rag-preparation-' . md5( ( defined( 'DB_NAME' ) ? DB_NAME : '' ) . $wpdb->prefix );
+ $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $name ) );
+ }
+
+ public function initialize(): void {
+ global $wpdb;
+ $table = $this->table();
+ $charset = $wpdb->get_charset_collate();
+ $this->query( "CREATE TABLE IF NOT EXISTS {$table} (
+ run_id varchar(36) NOT NULL,
+ post_id bigint unsigned NOT NULL,
+ original_id bigint unsigned NOT NULL DEFAULT 0,
+ original_rid varchar(255) NOT NULL DEFAULT '',
+ original_seqid varchar(255) DEFAULT NULL,
+ upstream_rid varchar(255) NOT NULL DEFAULT '',
+ upstream_seqid varchar(255) NOT NULL DEFAULT '',
+ queued tinyint NOT NULL DEFAULT 0,
+ attempts tinyint unsigned NOT NULL DEFAULT 0,
+ outcome varchar(16) NOT NULL DEFAULT '',
+ recovered tinyint NOT NULL DEFAULT 0,
+ created_at bigint unsigned NOT NULL,
+ PRIMARY KEY (run_id, post_id),
+ KEY cleanup (created_at)
+ ) {$charset}" );
+ $this->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %d LIMIT 1000", time() - WEEK_IN_SECONDS ) );
+ }
+
+ // Best effort: a failed purge must not break finishing the run; initialize() trims leftovers.
+ public function purge( string $run ): void {
+ global $wpdb;
+ $table = $this->table();
+ for ( $i = 0; $i < 20; $i++ ) {
+ $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE run_id = %s LIMIT 5000", $run ) );
+ if ( ! is_int( $deleted ) || $deleted < 5000 ) {
+ return;
+ }
+ }
+ }
+
+ public function bounds(): array {
+ global $wpdb;
+ $sync = $this->sync_table();
+ return [
+ 'mapping_limit' => (int) $wpdb->get_var( "SELECT MAX(id) FROM {$sync}" ),
+ 'post_limit' => (int) $wpdb->get_var( "SELECT MAX(ID) FROM {$wpdb->posts}" ),
+ ];
+ }
+
+ public function snapshot_mappings( string $run, int $cursor, int $limit ): array {
+ global $wpdb;
+ $sync = $this->sync_table();
+ $rows = $this->rows( $wpdb->prepare(
+ "SELECT id, post_id, nuclia_rid, nuclia_seqid FROM {$sync}
+ WHERE id > %d AND id <= %d AND post_id > 0 AND nuclia_rid != ''
+ ORDER BY id LIMIT 1000", $cursor, $limit
+ ) );
+ $values = [];
+ foreach ( $rows as $row ) {
+ $values[] = $this->stage_values( $run, (int) $row->post_id, (int) $row->id, $row->nuclia_rid, $row->nuclia_seqid );
+ $cursor = (int) $row->id;
+ }
+ $this->stage( $values );
+ return [ 'cursor' => $cursor, 'done' => count( $rows ) < 1000 ];
+ }
+
+ public function snapshot_candidates( string $run, int $cursor, int $limit, array $types ): array {
+ global $wpdb;
+ if ( empty( $types ) ) {
+ return [ 'cursor' => $cursor, 'done' => true ];
+ }
+ $sync = $this->sync_table();
+ $in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
+ $rows = $this->rows( $wpdb->prepare(
+ "SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$sync} idx ON idx.post_id = p.ID
+ WHERE p.ID > %d AND p.ID <= %d AND idx.post_id IS NULL
+ AND p.post_type IN ({$in}) AND p.post_password = ''
+ AND ((p.post_type = 'attachment' AND p.post_status = 'inherit')
+ OR (p.post_type != 'attachment' AND p.post_status = 'publish'))
+ ORDER BY p.ID LIMIT 1000", $cursor, $limit, ...$types
+ ) );
+ $values = [];
+ foreach ( $rows as $row ) {
+ $values[] = $this->stage_values( $run, (int) $row->ID );
+ $cursor = (int) $row->ID;
+ }
+ $this->stage( $values );
+ return [ 'cursor' => $cursor, 'done' => count( $rows ) < 1000 ];
+ }
+
+ private function stage_values( string $run, int $id, int $original = 0, string $rid = '', ?string $seqid = null ): string {
+ global $wpdb;
+ return $wpdb->prepare( "(%s, %d, %d, %s, NULLIF(%s, ''), %d)", $run, $id, $original, $rid, $seqid ?? '', time() );
+ }
+
+ private function stage( array $values ): void {
+ if ( empty( $values ) ) {
+ return;
+ }
+ $table = $this->table();
+ $this->query( "INSERT IGNORE INTO {$table} (run_id, post_id, original_id, original_rid, original_seqid, created_at) VALUES " . implode( ',', $values ) );
+ }
+
+ public function record_page( string $run, array $resources ): void {
+ global $wpdb;
+ $table = $this->table();
+ foreach ( $resources as $resource ) {
+ $slug = (string) $resource['slug'];
+ if ( (string) (int) $slug !== $slug || (int) $slug <= 0 ) {
+ continue;
+ }
+ $this->query( $wpdb->prepare(
+ "UPDATE {$table} SET upstream_rid = %s, upstream_seqid = %s WHERE run_id = %s AND post_id = %d",
+ $resource['rid'], $resource['seqid'], $run, (int) $slug
+ ) );
+ }
+ }
+
+ // Mappings the KB listing did not return; each one is confirmed individually before apply_page() may delete it.
+ public function unseen( string $run, int $cursor, int $limit ): array {
+ global $wpdb;
+ $table = $this->table();
+ return $this->rows( $wpdb->prepare(
+ "SELECT post_id FROM {$table} WHERE run_id = %s AND original_id > 0 AND upstream_rid = '' AND post_id > %d ORDER BY post_id LIMIT %d",
+ $run, $cursor, $limit
+ ) );
+ }
+
+ public function apply_page( string $run, int $cursor, array $types ): array {
+ global $wpdb;
+ $table = $this->table();
+ $sync = $this->sync_table();
+ $rows = $this->rows( $wpdb->prepare(
+ "SELECT post_id FROM {$table} WHERE run_id = %s AND post_id > %d ORDER BY post_id LIMIT 1000", $run, $cursor
+ ) );
+ if ( empty( $rows ) ) {
+ return [ 'cursor' => $cursor, 'done' => true ];
+ }
+ $next = (int) end( $rows )->post_id;
+ $where = $wpdb->prepare( 's.run_id = %s AND s.post_id > %d AND s.post_id <= %d', $run, $cursor, $next );
+ // Compare the entire original mapping so newer background-sync results are never overwritten.
+ $original = "idx.id = s.original_id AND idx.post_id = s.post_id AND idx.nuclia_rid = s.original_rid
+ AND COALESCE(idx.nuclia_seqid, '') = COALESCE(s.original_seqid, '')";
+ $this->query( "DELETE idx FROM {$sync} idx INNER JOIN {$table} s ON {$original} WHERE {$where} AND s.upstream_rid = ''" );
+ $this->query( "UPDATE {$sync} idx INNER JOIN {$table} s ON {$original}
+ SET idx.nuclia_rid = s.upstream_rid, idx.nuclia_seqid = IF(s.upstream_seqid != '', s.upstream_seqid, COALESCE(s.original_seqid, ''))
+ WHERE {$where} AND s.upstream_rid != ''" );
+ if ( ! empty( $types ) ) {
+ $in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
+ $eligible = $wpdb->prepare( "p.post_type IN ({$in})", ...$types );
+ $this->query( "INSERT INTO {$sync} (post_id, nuclia_rid, nuclia_seqid)
+ SELECT s.post_id, s.upstream_rid, s.upstream_seqid FROM {$table} s
+ INNER JOIN {$wpdb->posts} p ON p.ID = s.post_id LEFT JOIN {$sync} idx ON idx.post_id = s.post_id
+ WHERE {$where} AND s.original_id = 0 AND s.upstream_rid != '' AND idx.post_id IS NULL
+ AND {$eligible} AND p.post_password = ''
+ AND ((p.post_type = 'attachment' AND p.post_status = 'inherit')
+ OR (p.post_type != 'attachment' AND p.post_status = 'publish'))" );
+ $this->query( "UPDATE {$table} s INNER JOIN {$sync} idx ON idx.post_id = s.post_id AND idx.nuclia_rid = s.upstream_rid
+ SET s.recovered = 1 WHERE {$where} AND s.original_id = 0" );
+ }
+ return [ 'cursor' => $next, 'done' => count( $rows ) < 1000 ];
+ }
+
+ public function candidates( string $run, int $cursor, array $types ): array {
+ global $wpdb;
+ if ( empty( $types ) ) {
+ return [];
+ }
+ $table = $this->table();
+ $sync = $this->sync_table();
+ $in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
+ return $this->rows( $wpdb->prepare(
+ "SELECT s.post_id, p.post_type FROM {$table} s
+ INNER JOIN {$wpdb->posts} p ON p.ID = s.post_id
+ LEFT JOIN {$sync} idx ON idx.post_id = s.post_id
+ WHERE s.run_id = %s AND s.post_id > %d AND idx.post_id IS NULL
+ AND p.post_type IN ({$in}) AND p.post_password = ''
+ AND ((p.post_type = 'attachment' AND p.post_status = 'inherit')
+ OR (p.post_type != 'attachment' AND p.post_status = 'publish'))
+ ORDER BY s.post_id LIMIT 100", $run, $cursor, ...$types
+ ) );
+ }
+
+ public function mark_queued( string $run, int $id ): void {
+ global $wpdb;
+ $table = $this->table();
+ $this->query( $wpdb->prepare( "UPDATE {$table} SET queued = 1 WHERE run_id = %s AND post_id = %d", $run, $id ) );
+ }
+
+ public function outcome( string $run, int $id ): ?string {
+ global $wpdb;
+ $table = $this->table();
+ $value = $wpdb->get_var( $wpdb->prepare(
+ "SELECT IF(outcome = '', 'pending', outcome) FROM {$table} WHERE run_id = %s AND post_id = %d AND queued = 1", $run, $id
+ ) );
+ // wpdb::get_var() returns null for an empty SQL string as well as a missing row.
+ return 'pending' === $value ? '' : ( null === $value ? null : (string) $value );
+ }
+
+ public function finish_item( string $run, int $id, bool $success ): void {
+ global $wpdb;
+ $table = $this->table();
+ $this->query( $wpdb->prepare(
+ "UPDATE {$table} SET outcome = %s WHERE run_id = %s AND post_id = %d AND outcome = ''",
+ $success ? 'complete' : 'failed', $run, $id
+ ) );
+ }
+
+ public function begin_item( string $run, int $id ): bool {
+ global $wpdb;
+ $table = $this->table();
+ return $this->query( $wpdb->prepare(
+ "UPDATE {$table} SET attempts = attempts + 1
+ WHERE run_id = %s AND post_id = %d AND queued = 1 AND outcome = '' AND attempts < 3", $run, $id
+ ) ) > 0;
+ }
+
+ public function stats( string $run ): array {
+ global $wpdb;
+ $table = $this->table();
+ $rows = $this->rows( $wpdb->prepare(
+ "SELECT COALESCE(SUM(queued),0) total,
+ COALESCE(SUM(queued = 1 AND outcome = 'complete'),0) completed,
+ COALESCE(SUM(queued = 1 AND outcome = 'failed'),0) failed,
+ COALESCE(SUM(recovered),0) recovered
+ FROM {$table} WHERE run_id = %s", $run
+ ) );
+ return array_map( 'intval', (array) ( $rows[0] ?? (object) [ 'total' => 0, 'completed' => 0, 'failed' => 0, 'recovered' => 0 ] ) );
+ }
+
+ public function unfinished( string $run ): array {
+ global $wpdb;
+ $table = $this->table();
+ return $this->rows( $wpdb->prepare(
+ "SELECT s.post_id, COALESCE(p.post_type, '') post_type FROM {$table} s LEFT JOIN {$wpdb->posts} p ON p.ID = s.post_id
+ WHERE run_id = %s AND queued = 1 AND outcome = '' ORDER BY s.post_id LIMIT 100", $run
+ ) );
+ }
+
+ private function table(): string {
+ global $wpdb;
+ return esc_sql( $wpdb->prefix . self::TABLE );
+ }
+
+ private function sync_table(): string {
+ global $wpdb;
+ return esc_sql( $wpdb->prefix . SettingsRepository::SYNC_TABLE_NAME );
+ }
+
+ private function query( string $sql ): int {
+ global $wpdb;
+ $result = $wpdb->query( $sql );
+ if ( false === $result ) {
+ throw new RuntimeException( __( 'Could not save sync preparation. Retry after checking the WordPress database.', 'progress-agentic-rag-connector' ) );
+ }
+ return (int) $result;
+ }
+
+ private function rows( string $sql ): array {
+ global $wpdb;
+ $rows = $wpdb->get_results( $sql );
+ if ( ! is_array( $rows ) || ! empty( $wpdb->last_error ) ) {
+ throw new RuntimeException( __( 'Could not read sync preparation. Retry after checking the WordPress database.', 'progress-agentic-rag-connector' ) );
+ }
+ return $rows;
+ }
+}
diff --git a/src/Indexing/Scheduler.php b/src/Indexing/Scheduler.php
index ec445af..40a1aff 100644
--- a/src/Indexing/Scheduler.php
+++ b/src/Indexing/Scheduler.php
@@ -76,13 +76,16 @@ public function backend_message(): string {
*/
public function schedule_single_action( int $timestamp, string $hook, array $args, string $group ): bool {
if ( function_exists( 'as_schedule_single_action' ) ) {
- as_schedule_single_action( $timestamp, $hook, $args, $group );
- return true;
+ return (int) as_schedule_single_action( $timestamp, $hook, $args, $group ) > 0;
}
return false;
}
+ public function has_action( string $hook, array $args, string $group ): bool {
+ return function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( $hook, $args, $group );
+ }
+
/**
* Unschedule all matching background actions.
*
diff --git a/templates/admin/indexation.php b/templates/admin/indexation.php
index 5fc6405..aaa7108 100644
--- a/templates/admin/indexation.php
+++ b/templates/admin/indexation.php
@@ -96,7 +96,7 @@
-
+
@@ -135,5 +135,4 @@
-
diff --git a/templates/admin/manual-sync-modal.php b/templates/admin/manual-sync-modal.php
index b1f7c28..56cf17c 100644
--- a/templates/admin/manual-sync-modal.php
+++ b/templates/admin/manual-sync-modal.php
@@ -12,7 +12,7 @@