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
2 changes: 2 additions & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-artifacts-registry.php';
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-artifact-status.php';
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-artifact-hasher.php';
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-workspace-preload-artifact.php';
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-installed-artifact.php';
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-update-plan.php';
require_once AGENTS_API_PATH . 'src/Packages/class-wp-agent-package-update-planner.php';
Expand Down Expand Up @@ -84,6 +85,7 @@
require_once AGENTS_API_PATH . 'src/Registry/register-agents.php';
require_once AGENTS_API_PATH . 'src/Registry/register-agent-runtime-bundle-importer.php';
require_once AGENTS_API_PATH . 'src/Packages/register-agent-package-artifacts.php';
add_action( 'wp_agent_package_artifacts_init', array( 'WP_Agent_Workspace_Preload_Artifact', 'register' ) );
require_once AGENTS_API_PATH . 'src/Workspace/class-wp-agent-workspace-scope.php';
require_once AGENTS_API_PATH . 'src/Workspace/class-wp-agent-safe-execution-workspace.php';
require_once AGENTS_API_PATH . 'src/Workspace/register-safe-execution-workspace.php';
Expand Down
187 changes: 187 additions & 0 deletions src/Packages/class-wp-agent-workspace-preload-artifact.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php
/**
* Generic workspace preload package artifact contract.
*
* @package AgentsAPI
*/

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'WP_Agent_Workspace_Preload_Artifact' ) ) {
/**
* Normalizes and validates portable workspace preload artifacts.
*/
final class WP_Agent_Workspace_Preload_Artifact {

public const TYPE = 'agent-runtime/workspace-preload';
public const SCHEMA = 'agent-runtime/workspace-preload/v1';

/**
* Registers the generic workspace preload artifact type.
*
* @return void
*/
public static function register(): void {
wp_register_agent_package_artifact_type(
self::TYPE,
array(
'label' => 'Workspace preload',
'description' => 'Portable repository declarations for runtimes that can pre-materialize coding workspaces.',
'validate_callback' => array( self::class, 'validate' ),
'import_callback' => array( self::class, 'import' ),
'meta' => array(
'schema' => self::SCHEMA,
'materializer' => 'runtime',
),
)
);
}

/**
* Validates a workspace preload artifact payload.
*
* @param WP_Agent_Package_Artifact $artifact Artifact declaration.
* @param array<string,mixed> $context Consumer context.
* @return array<string,mixed>|WP_Error Normalized contract or validation error.
*/
public static function validate( WP_Agent_Package_Artifact $artifact, array $context = array() ) {
return self::normalized_from_context( $artifact, $context );
}

/**
* Imports a workspace preload artifact as a runtime materialization contract.
*
* Core does not clone, mount, or persist workspaces. Runtime adopters consume
* the returned contract and decide how to materialize repositories safely.
*
* @param WP_Agent_Package_Artifact $artifact Artifact declaration.
* @param array<string,mixed> $context Consumer context.
* @return array<string,mixed>|WP_Error Normalized import contract or validation error.
*/
public static function import( WP_Agent_Package_Artifact $artifact, array $context = array() ) {
$contract = self::normalized_from_context( $artifact, $context );
if ( is_wp_error( $contract ) ) {
return $contract;
}

return array(
'status' => 'materialization-contract',
'artifact' => $contract,
);
}

/**
* Normalizes raw payload into the stable workspace preload contract.
*
* @param array<string,mixed> $payload Raw payload.
* @return array<string,mixed>|WP_Error Normalized payload or validation error.
*/
public static function normalize_payload( array $payload ) {
$repositories = $payload['repositories'] ?? null;
if ( ! is_array( $repositories ) ) {
return new WP_Error( 'wp_agent_workspace_preload_repositories_invalid', 'Workspace preload payload requires a repositories array.' );
}

$normalized = array();
foreach ( $repositories as $index => $repository ) {
if ( ! is_array( $repository ) ) {
return new WP_Error( 'wp_agent_workspace_preload_repository_invalid', 'Workspace preload repository entries must be objects.', array( 'index' => $index ) );
}

$name = self::string_value( $repository['name'] ?? '' );
$url = self::string_value( $repository['url'] ?? '' );
$ref = self::string_value( $repository['ref'] ?? '' );

if ( '' === $name || sanitize_title( $name ) !== $name ) {
return new WP_Error( 'wp_agent_workspace_preload_repository_name_invalid', 'Workspace preload repository name must be a non-empty slug.', array( 'index' => $index ) );
}

if ( '' === $url || ! self::is_supported_repository_url( $url ) ) {
return new WP_Error( 'wp_agent_workspace_preload_repository_url_invalid', 'Workspace preload repository url must be an HTTPS or SSH Git URL.', array( 'index' => $index ) );
}

$entry = array(
'name' => $name,
'url' => $url,
);

if ( '' !== $ref ) {
$entry['ref'] = $ref;
}

$normalized[] = $entry;
}

if ( array() === $normalized ) {
return new WP_Error( 'wp_agent_workspace_preload_repositories_empty', 'Workspace preload payload requires at least one repository.' );
}

$result = array(
'schema' => self::SCHEMA,
'repositories' => $normalized,
);

if ( isset( $payload['meta'] ) && is_array( $payload['meta'] ) ) {
$result['meta'] = self::string_keyed_array( $payload['meta'] );
}

return $result;
}

/**
* Extracts and normalizes payload from an artifact callback context.
*
* @param WP_Agent_Package_Artifact $artifact Artifact declaration.
* @param array<string,mixed> $context Consumer context.
* @return array<string,mixed>|WP_Error Normalized contract or validation error.
*/
private static function normalized_from_context( WP_Agent_Package_Artifact $artifact, array $context ) {
$target = is_array( $context['target'] ?? null ) ? self::string_keyed_array( $context['target'] ) : array();
$payload = is_array( $context['payload'] ?? null ) ? self::string_keyed_array( $context['payload'] ) : ( is_array( $target['payload'] ?? null ) ? self::string_keyed_array( $target['payload'] ) : array() );
if ( array() === $payload ) {
return new WP_Error( 'wp_agent_workspace_preload_payload_missing', 'Workspace preload artifact callbacks require a payload array.', array( 'artifact' => $artifact->get_slug() ) );
}

$normalized = self::normalize_payload( $payload );
if ( is_wp_error( $normalized ) ) {
return $normalized;
}

return array(
'type' => self::TYPE,
'slug' => $artifact->get_slug(),
'source' => $artifact->get_source(),
'payload' => $normalized,
);
}

private static function is_supported_repository_url( string $url ): bool {
return str_starts_with( $url, 'https://' ) || preg_match( '/^git@[A-Za-z0-9._-]+:[A-Za-z0-9._\/-]+\.git$/', $url );
}

private static function string_value( mixed $value ): string {
if ( is_scalar( $value ) || $value instanceof Stringable ) {
return trim( (string) $value );
}

return '';
}

/**
* @param array<mixed> $values Raw values.
* @return array<string,mixed>
*/
private static function string_keyed_array( array $values ): array {
$prepared = array();
foreach ( $values as $key => $value ) {
if ( is_string( $key ) ) {
$prepared[ $key ] = $value;
}
}

return $prepared;
}

private function __construct() {}
}
}
102 changes: 102 additions & 0 deletions tests/workspace-preload-artifact-smoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
/**
* Pure-PHP smoke test for the workspace preload package artifact contract.
*
* Run with: php tests/workspace-preload-artifact-smoke.php
*
* @package AgentsAPI\Tests
*/

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}

if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private string $code;
private string $message;
private $data;

public function __construct( string $code = '', string $message = '', $data = null ) {
$this->code = $code;
$this->message = $message;
$this->data = $data;
}

public function get_error_code(): string {
return $this->code;
}

public function get_error_message(): string {
return $this->message;
}

public function get_error_data() {
return $this->data;
}
}
}

$failures = array();
$passes = 0;

echo "agents-api-workspace-preload-artifact-smoke\n";

require_once __DIR__ . '/agents-api-smoke-helpers.php';
agents_api_smoke_require_module();
do_action( 'init' );

echo "\n[1] Core registers the generic workspace preload artifact type:\n";
$type = wp_get_agent_package_artifact_type( 'agent-runtime/workspace-preload' );
agents_api_smoke_assert_equals( true, $type instanceof WP_Agent_Package_Artifact_Type, 'artifact type is registered', $failures, $passes );
agents_api_smoke_assert_equals( 'agent-runtime/workspace-preload/v1', $type->get_meta()['schema'] ?? '', 'artifact type advertises the stable schema', $failures, $passes );

$artifact = new WP_Agent_Package_Artifact(
array(
'type' => 'agent-runtime/workspace-preload',
'slug' => 'review-repos',
'source' => 'extensions/agent-runtime/workspace-preload/review-repos.json',
)
);

$payload = array(
'repositories' => array(
array(
'name' => 'static-site-importer',
'url' => 'https://github.com/chubes4/static-site-importer.git',
),
array(
'name' => 'private-runtime',
'url' => 'git@github.a8c.com:Automattic/private-runtime.git',
'ref' => 'trunk',
),
),
);

echo "\n[2] Validation normalizes repository preload payloads without materializing them:\n";
$validated = WP_Agent_Package_Artifact_Callbacks::validate( $artifact, array( 'target' => array( 'payload' => $payload ) ) );
agents_api_smoke_assert_equals( false, is_wp_error( $validated ), 'valid payload passes validation', $failures, $passes );
agents_api_smoke_assert_equals( 'agent-runtime/workspace-preload', $validated['type'] ?? '', 'validated contract keeps the generic artifact type', $failures, $passes );
agents_api_smoke_assert_equals( 'agent-runtime/workspace-preload/v1', $validated['payload']['schema'] ?? '', 'validated payload carries schema version', $failures, $passes );
agents_api_smoke_assert_equals( 'trunk', $validated['payload']['repositories'][1]['ref'] ?? '', 'repository refs are preserved for runtime materializers', $failures, $passes );

echo "\n[3] Import returns a runtime materialization contract, not a product-specific side effect:\n";
$imported = WP_Agent_Package_Artifact_Callbacks::import( $artifact, array( 'target' => array( 'payload' => $payload ) ) );
agents_api_smoke_assert_equals( 'materialization-contract', $imported['status'] ?? '', 'import status identifies a materialization contract', $failures, $passes );
agents_api_smoke_assert_equals( 'review-repos', $imported['artifact']['slug'] ?? '', 'import contract identifies the artifact slug', $failures, $passes );

echo "\n[4] Invalid repository declarations fail before runtime adoption:\n";
$invalid = WP_Agent_Workspace_Preload_Artifact::normalize_payload(
array(
'repositories' => array(
array(
'name' => 'Bad Name',
'url' => 'file:///tmp/repo',
),
),
)
);
agents_api_smoke_assert_equals( true, is_wp_error( $invalid ), 'invalid repository payload returns WP_Error', $failures, $passes );
agents_api_smoke_assert_equals( 'wp_agent_workspace_preload_repository_name_invalid', $invalid->get_error_code(), 'invalid names are rejected before urls are considered', $failures, $passes );

agents_api_smoke_finish( 'Agents API workspace preload artifact contract', $failures, $passes );