diff --git a/agents-api.php b/agents-api.php index 3382ce1..1aa3537 100644 --- a/agents-api.php +++ b/agents-api.php @@ -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'; @@ -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'; diff --git a/src/Packages/class-wp-agent-workspace-preload-artifact.php b/src/Packages/class-wp-agent-workspace-preload-artifact.php new file mode 100644 index 0000000..a257934 --- /dev/null +++ b/src/Packages/class-wp-agent-workspace-preload-artifact.php @@ -0,0 +1,187 @@ + '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 $context Consumer context. + * @return array|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 $context Consumer context. + * @return array|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 $payload Raw payload. + * @return array|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 $context Consumer context. + * @return array|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 $values Raw values. + * @return array + */ + 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() {} + } +} diff --git a/tests/workspace-preload-artifact-smoke.php b/tests/workspace-preload-artifact-smoke.php new file mode 100644 index 0000000..642a23a --- /dev/null +++ b/tests/workspace-preload-artifact-smoke.php @@ -0,0 +1,102 @@ +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 );