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
5 changes: 3 additions & 2 deletions inc/Core/Agents/AgentBundler.php
Original file line number Diff line number Diff line change
Expand Up @@ -1080,8 +1080,9 @@ public function import( array $bundle, ?string $new_slug = null, int $owner_id =
$summary['conflicts'] = $conflicts;
$summary['runtime_drift'] = $runtime_drift;

if ( ! AgentBundleArtifactState::persist_for_agent( $agent_id, array_values( $artifact_records ) ) ) {
throw new \RuntimeException( 'Failed to persist installed bundle artifact state.' );
$artifact_persist_result = AgentBundleArtifactState::persist_for_agent_result( $agent_id, array_values( $artifact_records ) );
if ( is_wp_error( $artifact_persist_result ) ) {
throw new \RuntimeException( $artifact_persist_result->get_error_message() );
}
if ( ! $this->agents_repo->update_agent( $agent_id, array( 'agent_config' => $config ) ) ) {
throw new \RuntimeException( 'Failed to persist final agent_config with bundle metadata.' );
Expand Down
22 changes: 22 additions & 0 deletions inc/Core/Database/BundleArtifacts/InstalledBundleArtifacts.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ final class InstalledBundleArtifacts extends BaseRepository {
*/
private static bool $table_ensured = false;

/**
* Last write failure context for callers that need diagnostics.
*
* @var array<string,mixed>
*/
private array $last_error_context = array();

/**
* Wire cleanup hooks once per request.
*
Expand Down Expand Up @@ -105,6 +112,7 @@ public static function create_table(): void {
*/
public function upsert( AgentBundleInstalledArtifact $artifact, int $agent_id = 0 ): bool {
self::ensure_table();
$this->last_error_context = array();

$artifact_row = $artifact->to_array();
$installed_payload = $artifact->installed_payload();
Expand Down Expand Up @@ -142,13 +150,27 @@ public function upsert( AgentBundleInstalledArtifact $artifact, int $agent_id =
$result = $this->wpdb->replace( $this->table_name, $row, $format );
}
if ( false === $result ) {
$this->last_error_context = array(
'message' => '' !== (string) $this->wpdb->last_error ? (string) $this->wpdb->last_error : 'Unknown database write failure.',
'table' => $this->table_name,
'row' => $this->safe_log_context( $row ),
);
$this->log_db_error( 'upsert installed bundle artifact', $this->safe_log_context( $row ) );
return false;
}

return true;
}

/**
* Return the last write failure context, if any.
*
* @return array<string,mixed>
*/
public function last_error_context(): array {
return $this->last_error_context;
}

/**
* Record install-time state from a bundle artifact payload.
*
Expand Down
108 changes: 97 additions & 11 deletions inc/Engine/Bundle/AgentBundleArtifactState.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,30 +43,116 @@ public static function installed_for_agent( array $agent ): array {
* @return bool
*/
public static function persist_for_agent( int $agent_id, array $artifacts ): bool {
return ! ( self::persist_for_agent_result( $agent_id, $artifacts ) instanceof \WP_Error );
}

/**
* Persist installed artifact rows for an agent with structured failure details.
*
* @param int $agent_id Agent ID.
* @param array<int,array<string,mixed>|AgentBundleInstalledArtifact> $artifacts Artifact rows.
* @return true|\WP_Error
*/
public static function persist_for_agent_result( int $agent_id, array $artifacts ): bool|\WP_Error {
if ( $agent_id <= 0 ) {
return false;
return new \WP_Error(
'datamachine_bundle_artifact_invalid_agent_id',
'Cannot persist installed bundle artifact state without a valid agent ID.',
array( 'agent_id' => $agent_id )
);
}

$store = new InstalledBundleArtifacts();
$ok = true;
foreach ( $artifacts as $artifact ) {
$store = new InstalledBundleArtifacts();
$errors = array();
foreach ( $artifacts as $index => $artifact ) {
$installed = null;
try {
$installed = $artifact instanceof AgentBundleInstalledArtifact ? $artifact : AgentBundleInstalledArtifact::from_array( (array) $artifact );
$ok = $store->upsert( $installed, $agent_id ) && $ok;
} catch ( \Throwable $error ) {
$ok = false;
$context = self::failure_context( $agent_id, (int) $index, $artifact, $error->getMessage(), get_class( $error ) );
$errors[] = $context;
do_action(
'datamachine_log',
'warning',
'Failed to persist installed bundle artifact state.',
$context
);
continue;
}

if ( ! $store->upsert( $installed, $agent_id ) ) {
$db_context = $store->last_error_context();
$message = (string) ( $db_context['message'] ?? 'Database write returned false.' );
$context = self::failure_context( $agent_id, (int) $index, $installed, $message, 'database_write_failed' );
if ( ! empty( $db_context ) ) {
$context['database'] = $db_context;
}
$errors[] = $context;
do_action(
'datamachine_log',
'warning',
'Failed to persist installed bundle artifact state.',
array(
'agent_id' => $agent_id,
'error' => $error->getMessage(),
)
$context
);
}
}

return $ok;
if ( empty( $errors ) ) {
return true;
}

return new \WP_Error(
'datamachine_bundle_artifact_persist_failed',
self::failure_message( $agent_id, $errors ),
array(
'agent_id' => $agent_id,
'errors' => $errors,
)
);
}

/**
* Build safe per-artifact failure context.
*
* @param int $agent_id Agent ID.
* @param int $index Artifact index.
* @param array<string,mixed>|AgentBundleInstalledArtifact $artifact Artifact row.
* @param string $message Failure message.
* @param string $error_class Failure class or category.
* @return array<string,mixed>
*/
private static function failure_context( int $agent_id, int $index, array|AgentBundleInstalledArtifact $artifact, string $message, string $error_class ): array {
$row = $artifact instanceof AgentBundleInstalledArtifact ? $artifact->to_array() : $artifact;

return array(
'agent_id' => $agent_id,
'artifact_index' => $index,
'artifact_type' => (string) ( $row['artifact_type'] ?? '' ),
'artifact_id' => (string) ( $row['artifact_id'] ?? '' ),
'source_path' => (string) ( $row['source_path'] ?? '' ),
'error' => $message,
'error_class' => $error_class,
);
}

/**
* Format a concise operator-facing failure message.
*
* @param int $agent_id Agent ID.
* @param array<int,array<string,mixed>> $errors Failure contexts.
*/
private static function failure_message( int $agent_id, array $errors ): string {
$parts = array();
foreach ( array_slice( $errors, 0, 3 ) as $error ) {
$label = trim( (string) ( $error['artifact_type'] ?? '' ) . ':' . (string) ( $error['artifact_id'] ?? '' ), ':' );
$label = '' !== $label ? $label : 'artifact #' . (string) ( $error['artifact_index'] ?? '?' );
$parts[] = sprintf( '%s (%s)', $label, (string) ( $error['error'] ?? 'unknown error' ) );
}

if ( count( $errors ) > count( $parts ) ) {
$parts[] = sprintf( '%d more failure(s)', count( $errors ) - count( $parts ) );
}

return sprintf( 'Failed to persist installed bundle artifact state for agent %d: %s', $agent_id, implode( '; ', $parts ) );
}
}
28 changes: 28 additions & 0 deletions tests/Unit/Core/Agents/AgentBundlerImportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,34 @@ public function test_import_ensures_installed_artifact_table_at_runtime(): void
$this->assertCount( 3, $artifacts, 'Installed artifact rows are written after runtime table ensure.' );
}

public function test_artifact_persistence_reports_artifact_failure_details(): void {
$result = AgentBundleArtifactState::persist_for_agent_result(
123,
array(
array(
'bundle_slug' => 'broken-bundle',
'bundle_version' => '1',
'artifact_type' => 'not a valid type',
'artifact_id' => 'broken-artifact',
'source_path' => 'broken.json',
'installed_hash' => 'abc123',
'current_hash' => 'abc123',
'installed_at' => '2026-05-31 00:00:00',
'updated_at' => '2026-05-31 00:00:00',
),
)
);

$this->assertWPError( $result );
$this->assertSame( 'datamachine_bundle_artifact_persist_failed', $result->get_error_code() );
$this->assertStringContainsString( 'broken-artifact', $result->get_error_message() );
$this->assertStringContainsString( 'installed bundle artifact_type must be one of the registered bundle artifact types', $result->get_error_message() );

$data = $result->get_error_data();
$this->assertSame( 'broken-artifact', $data['errors'][0]['artifact_id'] ?? null );
$this->assertSame( 'broken.json', $data['errors'][0]['source_path'] ?? null );
}

public function test_directory_value_object_import_preserves_workflow_runtime_seed_fields(): void {
$bundle = $this->fixture_bundle( 'directory-import-agent' );
$bundle['flows'][0]['flow_config']['1_step-uuid_1'] = array_merge(
Expand Down
Loading