Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions data-machine.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,12 @@ function () {
require_once __DIR__ . '/inc/Abilities/ChatAbilities.php';
require_once __DIR__ . '/inc/Abilities/InternalLinkingAbilities.php';
require_once __DIR__ . '/inc/Abilities/Content/BlockSanitizer.php';
require_once __DIR__ . '/inc/Abilities/Content/PendingDiffStore.php';
require_once __DIR__ . '/inc/Abilities/Content/CanonicalDiffPreview.php';
require_once __DIR__ . '/inc/Abilities/Content/GetPostBlocksAbility.php';
require_once __DIR__ . '/inc/Abilities/Content/EditPostBlocksAbility.php';
require_once __DIR__ . '/inc/Abilities/Content/ReplacePostBlocksAbility.php';
require_once __DIR__ . '/inc/Abilities/Content/ResolveDiffAbility.php';
require_once __DIR__ . '/inc/Abilities/Content/UpsertPostAbility.php';
require_once __DIR__ . '/inc/Abilities/Content/ContentActionHandlers.php';
// GitHubAbilities moved to data-machine-code extension.
require_once __DIR__ . '/inc/Abilities/Fetch/FetchFilesAbility.php';
require_once __DIR__ . '/inc/Abilities/Email/EmailAbilities.php';
Expand Down Expand Up @@ -243,9 +242,10 @@ function () {
new \DataMachine\Abilities\Content\ReplacePostBlocksAbility();
new \DataMachine\Abilities\Content\InsertContentAbility();
new \DataMachine\Abilities\Content\UpsertPostAbility();
new \DataMachine\Abilities\Content\ResolveDiffAbility();

// ActionPolicy + pending-action resolver (generic successor to ResolveDiffAbility).
// ActionPolicy + unified pending-action resolver. Content abilities register
// themselves on `datamachine_pending_action_handlers` via
// inc/Abilities/Content/ContentActionHandlers.php (required above).
new \DataMachine\Engine\AI\Actions\ResolvePendingActionAbility();
new \DataMachine\Engine\AI\Actions\ResolvePendingAction();

Expand Down
60 changes: 60 additions & 0 deletions docs/development/hooks/core-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,66 @@ add_filter('datamachine_session_title_prompt', function($prompt, $context) {
- Create privacy-safe titles that don't expose chat content
- Customize title style per site or plugin

## Preview & Approval Filters

Data Machine ships **one** preview/approve primitive: `PendingActionStore`
plus `ResolvePendingActionAbility`. Any tool that wants the user to see a
change before it takes effect stages its invocation via
`PendingActionHelper::stage()` and registers an apply callback on
`datamachine_pending_action_handlers`. The core content abilities
(`edit_post_blocks`, `replace_post_blocks`, `insert_content`), the socials
publishers, and anything else opting into `action_policy=preview` all route
through the same lane.

> **Which preview primitive should I use?** There is only one. Call
> `PendingActionHelper::stage()` to stage a pending invocation and register
> your apply callback on `datamachine_pending_action_handlers`. The
> `ResolvePendingActionAbility` (ability slug
> `datamachine/resolve-pending-action`, REST route
> `POST /datamachine/v1/actions/resolve`, chat tool
> `resolve_pending_action`) finalizes every kind.

### `datamachine_pending_action_handlers`

**Purpose**: Register the apply + permission callbacks for a pending-action
kind.

```php
add_filter( 'datamachine_pending_action_handlers', function ( $handlers ) {
$handlers['my_kind'] = array(
'apply' => array( MyAbility::class, 'execute' ),
'can_resolve' => function ( array $payload, string $decision, int $user_id ) {
// Return true, false, or a WP_Error. Optional — defaults to
// "any user who can call resolve_pending_action".
return current_user_can( 'edit_posts' );
},
);
return $handlers;
} );
```

`apply` receives the stored `apply_input` array and must return either a
value (which is wrapped into the resolver response) or a `WP_Error` to
surface failure.

### `datamachine_pending_action_staged`

**Purpose**: Fires when a tool invocation has been staged and is awaiting
user resolution. Use this to notify users, log audit trails, or mirror the
payload into a visible queue.

### `datamachine_pending_action_resolved`

**Purpose**: Fires after a staged action is accepted or rejected. Receives
`$decision, $action_id, $kind, $payload, $result`.

### `datamachine_tool_action_policy`

**Purpose**: Last-layer override of the resolved action policy
(`direct | preview | forbidden`) for a single tool invocation. Runs after
`ActionPolicyResolver` has consulted deny lists, per-agent overrides, tool
declarations, and mode presets.

## Pipeline Operations Filters

### `datamachine_create_pipeline`
Expand Down
63 changes: 20 additions & 43 deletions inc/Abilities/Content/CanonicalDiffPreview.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
<?php
/**
* CanonicalDiffPreview — shared preview payload builder for content abilities.
* CanonicalDiffPreview — shared preview-payload builder for content abilities.
*
* Produces the canonical diff shape that gets nested inside the
* PendingAction envelope as `preview_data`. The three content abilities
* (edit_post_blocks, replace_post_blocks, insert_content) all funnel
* through `build()` so that consumers (Gutenberg diff block, CLI review
* UIs, notification payloads) can read a single stable shape.
*
* Storage is handled by PendingActionHelper::stage(); this class is a
* pure formatter.
*
* @package DataMachine\Abilities\Content
* @since 0.60.0
Expand All @@ -15,11 +24,18 @@ class CanonicalDiffPreview {
/**
* Build the canonical diff payload returned by preview-mode content abilities.
*
* @param array $args Canonical diff data.
* Callers pass the same action_id that will be used to stage the payload
* via PendingActionHelper::stage(). The returned array embeds `actionId`
* so Gutenberg / CLI consumers can round-trip user decisions without
* separately tracking the envelope id.
*
* @param array $args Canonical diff data. Supported keys: action_id,
* diff_type, original_content, replacement_content,
* summary, items, position, insertion_point, editor.
* @return array
*/
public static function build( array $args ): array {
$diff_id = (string) ( $args['diff_id'] ?? '' );
$action_id = (string) ( $args['action_id'] ?? '' );
$diff_type = (string) ( $args['diff_type'] ?? 'edit' );
$original_content = (string) ( $args['original_content'] ?? '' );
$replacement_content = (string) ( $args['replacement_content'] ?? '' );
Expand All @@ -30,7 +46,7 @@ public static function build( array $args ): array {
$editor = isset( $args['editor'] ) && is_array( $args['editor'] ) ? $args['editor'] : array();

$diff = array(
'diffId' => $diff_id,
'actionId' => $action_id,
'diffType' => $diff_type,
'originalContent' => $original_content,
'replacementContent' => $replacement_content,
Expand Down Expand Up @@ -59,43 +75,4 @@ public static function build( array $args ): array {

return $diff;
}

/**
* Store pending diff metadata for later resolution.
*
* @param string $diff_id Diff identifier.
* @param array $args Pending diff metadata.
* @return void
*/
public static function store_pending( string $diff_id, array $args ): void {
PendingDiffStore::store( $diff_id, array(
'type' => (string) ( $args['type'] ?? '' ),
'post_id' => absint( $args['post_id'] ?? 0 ),
'input' => isset( $args['input'] ) && is_array( $args['input'] ) ? $args['input'] : array(),
'diff' => isset( $args['diff'] ) && is_array( $args['diff'] ) ? $args['diff'] : array(),
) );
}

/**
* Wrap a preview response in the canonical shape.
*
* @param int $post_id Post ID being edited.
* @param string $message Human summary.
* @param array $diff Canonical diff payload.
* @param array $extra Additional response fields.
* @return array
*/
public static function response( int $post_id, string $message, array $diff, array $extra = array() ): array {
return array_merge(
array(
'success' => true,
'preview' => true,
'post_id' => $post_id,
'diff_id' => $diff['diffId'] ?? '',
'diff' => $diff,
'message' => $message,
),
$extra
);
}
}
107 changes: 107 additions & 0 deletions inc/Abilities/Content/ContentActionHandlers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php
/**
* ContentActionHandlers — register content abilities on the unified
* PendingActionStore lane.
*
* The three content abilities (edit_post_blocks, replace_post_blocks,
* insert_content) stage their previews via PendingActionHelper::stage().
* This file registers their `apply` + `can_resolve` callbacks on the
* `datamachine_pending_action_handlers` filter so that
* ResolvePendingActionAbility can replay the staged invocation when the
* user accepts.
*
* Each handler re-invokes the original ability's execute() with `preview`
* stripped, so the same code path that ran the preview also applies it.
*
* @package DataMachine\Abilities\Content
* @since 0.79.0
*/

namespace DataMachine\Abilities\Content;

defined( 'ABSPATH' ) || exit;

add_filter(
'datamachine_pending_action_handlers',
static function ( $handlers ) {
if ( ! is_array( $handlers ) ) {
$handlers = array();
}

$handlers['edit_post_blocks'] = array(
'apply' => static function ( array $apply_input ) {
unset( $apply_input['preview'] );
return EditPostBlocksAbility::execute( $apply_input );
},
'can_resolve' => static function ( array $payload, string $decision, int $user_id ) {
return ContentActionHandlers::can_resolve_post_edit( $payload, $user_id );
},
);

$handlers['replace_post_blocks'] = array(
'apply' => static function ( array $apply_input ) {
unset( $apply_input['preview'] );
return ReplacePostBlocksAbility::execute( $apply_input );
},
'can_resolve' => static function ( array $payload, string $decision, int $user_id ) {
return ContentActionHandlers::can_resolve_post_edit( $payload, $user_id );
},
);

$handlers['insert_content'] = array(
'apply' => static function ( array $apply_input ) {
$apply_input['preview'] = false;
return InsertContentAbility::execute( $apply_input );
},
'can_resolve' => static function ( array $payload, string $decision, int $user_id ) {
return ContentActionHandlers::can_resolve_post_edit( $payload, $user_id );
},
);

return $handlers;
}
);

/**
* Helper container for the shared can_resolve check.
*
* The three content abilities all mutate post content, so the gate is the
* same: the resolving user must have `edit_post` on the target post.
*/
class ContentActionHandlers {

/**
* Gate resolution of a content-edit pending action.
*
* @param array $payload Stored PendingAction payload.
* @param int $user_id Resolving user ID.
* @return bool|\WP_Error True if allowed, WP_Error with explanation otherwise.
*/
public static function can_resolve_post_edit( array $payload, int $user_id ) {
$apply_input = isset( $payload['apply_input'] ) && is_array( $payload['apply_input'] )
? $payload['apply_input']
: array();

$post_id = absint( $apply_input['post_id'] ?? 0 );

if ( $post_id <= 0 ) {
return new \WP_Error(
'datamachine_invalid_pending_action',
__( 'Stored pending action is missing a target post.', 'data-machine' )
);
}

if ( $user_id > 0 ) {
if ( user_can( $user_id, 'edit_post', $post_id ) ) {
return true;
}
} elseif ( current_user_can( 'edit_post', $post_id ) ) {
return true;
}

return new \WP_Error(
'datamachine_forbidden',
__( 'You do not have permission to edit this post.', 'data-machine' )
);
}
}
38 changes: 25 additions & 13 deletions inc/Abilities/Content/EditPostBlocksAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
namespace DataMachine\Abilities\Content;

use DataMachine\Abilities\PermissionHelper;
use DataMachine\Engine\AI\Actions\PendingActionHelper;
use DataMachine\Engine\AI\Actions\PendingActionStore;

defined( 'ABSPATH' ) || exit;

Expand Down Expand Up @@ -275,9 +277,9 @@ function ( $content ) use ( $find, $replace ) {

$new_content = BlockSanitizer::sanitizeAndSerialize( $blocks );

// --- Preview mode: store pending edit, return diff data ---
// --- Preview mode: stage pending action, return preview envelope ---
if ( $preview ) {
$diff_id = PendingDiffStore::generate_id();
$action_id = PendingActionStore::generate_id();

// Build per-edit diff data for the frontend.
$diffs = array();
Expand All @@ -294,7 +296,7 @@ function ( $content ) use ( $find, $replace ) {

$diff = CanonicalDiffPreview::build(
array(
'diff_id' => $diff_id,
'action_id' => $action_id,
'diff_type' => 'edit',
'original_content' => implode( "\n", array_column( $diffs, 'originalContent' ) ),
'replacement_content' => implode( "\n", array_column( $diffs, 'replacementContent' ) ),
Expand All @@ -303,24 +305,34 @@ function ( $content ) use ( $find, $replace ) {
)
);

CanonicalDiffPreview::store_pending(
$diff_id,
$envelope = PendingActionHelper::stage(
array(
'type' => 'edit_post_blocks',
'post_id' => $post_id,
'input' => array(
'action_id' => $action_id,
'kind' => 'edit_post_blocks',
'summary' => sprintf( 'Preview edits to post #%d.', $post_id ),
'apply_input' => array(
'post_id' => $post_id,
'edits' => $edits,
),
'diff' => $diff,
'preview_data' => $diff,
'context' => array( 'post_id' => $post_id ),
)
);

return CanonicalDiffPreview::response(
$post_id,
'Preview generated. Accept or reject to apply changes.',
$diff,
if ( empty( $envelope['staged'] ) ) {
return array(
'success' => false,
'post_id' => $post_id,
'error' => $envelope['error'] ?? 'Failed to stage preview.',
);
}

return array_merge(
$envelope,
array(
'success' => true,
'is_preview' => true,
'post_id' => $post_id,
'changes_applied' => $changes,
)
);
Expand Down
Loading
Loading