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
108 changes: 75 additions & 33 deletions inc/Workspace/WorktreeContextInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,6 @@ public static function find_duplicate_task_ownership( array $worktrees ): array
$buckets = array();

foreach ( $worktrees as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$handle = (string) ( $row['handle'] ?? '' );
if ( '' === $handle ) {
continue;
Expand All @@ -369,7 +366,7 @@ public static function find_duplicate_task_ownership( array $worktrees ): array

$duplicates = array();
foreach ( $buckets as $bucket ) {
$handles = array_values( array_unique( (array) ( $bucket['handles'] ?? array() ) ) );
$handles = array_values( array_unique( $bucket['handles'] ) );
if ( count( $handles ) < 2 ) {
continue;
}
Expand Down Expand Up @@ -510,14 +507,14 @@ private static function resolve_primary_id( array $session, array $ids ): ?strin

// Pass 1: session_id across registered runtimes, in registration order.
foreach ( array_keys( $signatures ) as $runtime_id ) {
if ( isset( $ids[ $runtime_id ]['session_id'] ) && null !== $ids[ $runtime_id ]['session_id'] ) {
if ( isset( $ids[ $runtime_id ]['session_id'] ) ) {
return $ids[ $runtime_id ]['session_id'];
}
}

// Pass 2: any subkey across registered runtimes, in registration order.
foreach ( array_keys( $signatures ) as $runtime_id ) {
if ( ! isset( $ids[ $runtime_id ] ) || ! is_array( $ids[ $runtime_id ] ) ) {
if ( ! isset( $ids[ $runtime_id ] ) ) {
continue;
}
foreach ( $ids[ $runtime_id ] as $value ) {
Expand Down Expand Up @@ -592,7 +589,7 @@ private static function migrate_legacy_origin_session( array $session ): array {
'ids' => true,
);
foreach ( $session as $key => $value ) {
if ( ! is_string( $key ) || isset( $canonical_top_level[ $key ] ) ) {
if ( isset( $canonical_top_level[ $key ] ) ) {
continue;
}
$underscore = strpos( $key, '_' );
Expand All @@ -601,9 +598,6 @@ private static function migrate_legacy_origin_session( array $session ): array {
}
$runtime_id = substr( $key, 0, $underscore );
$subkey = substr( $key, $underscore + 1 );
if ( '' === $runtime_id || '' === $subkey ) {
continue;
}
if ( ! isset( $ids[ $runtime_id ] ) || ! is_array( $ids[ $runtime_id ] ) ) {
$ids[ $runtime_id ] = array();
}
Expand Down Expand Up @@ -840,12 +834,23 @@ public static function build_payload(): ?array {
$user_id = $dm->get_effective_user_id( 0 );
$agent_slug = $dm->resolve_agent_slug( array( 'user_id' => $user_id ) );

$files = array();
$files = array();
$memory_class = '\\DataMachine\\Core\\FilesRepository\\AgentMemory';
foreach ( self::MEMORY_FILES as $filename ) {
$memory = new \DataMachine\Core\FilesRepository\AgentMemory( $user_id, 0, $filename );
$result = $memory->get_all();
if ( ! empty( $result['success'] ) && is_string( $result['content'] ?? null ) && '' !== trim( $result['content'] ) ) {
$files[ $filename ] = $result['content'];
$memory = new $memory_class( $user_id, 0, $filename );
$content = null;
if ( is_callable( array( $memory, 'get_all' ) ) ) {
$result = call_user_func( array( $memory, 'get_all' ) );
$content = is_array( $result ) && ! empty( $result['success'] ) && is_string( $result['content'] ?? null ) ? $result['content'] : null;
} elseif ( is_callable( array( $memory, 'get_file_path' ) ) ) {
$file_path = call_user_func( array( $memory, 'get_file_path' ) );
if ( is_string( $file_path ) && is_readable( $file_path ) ) {
$content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- AgentMemory returns a validated local file path, not a remote URL.
}
}

if ( is_string( $content ) && '' !== trim( $content ) ) {
$files[ $filename ] = $content;
}
}

Expand Down Expand Up @@ -1029,12 +1034,10 @@ public static function inject( string $worktree_path, array $payload ): array|\W
if ( is_wp_error( $agents_projection ) ) {
return $agents_projection;
}
if ( is_array( $agents_projection ) ) {
$written = array_merge( $written, $agents_projection );
}
$written = array_merge( $written, $agents_projection );

$exclude_entries = self::INJECTED_PATHS;
if ( is_array( $agents_projection ) && ! empty( $agents_projection ) ) {
if ( ! empty( $agents_projection ) ) {
$exclude_entries[] = self::PROJECTED_AGENTS_PATH;
$exclude_entries[] = self::PROJECTED_AGENTS_MARKER_PATH;
$exclude_entries[] = self::PROJECTED_OPENCODE_CONFIG_PATH;
Expand Down Expand Up @@ -1065,7 +1068,7 @@ public static function inject( string $worktree_path, array $payload ): array|\W
*/
private static function project_site_agents_md( string $worktree_path, array $payload ): array|\WP_Error {
$source = isset( $payload['agents_md_path'] ) ? (string) $payload['agents_md_path'] : '';
if ( '' === $source || ! is_file( $source ) ) {
if ( '' === $source || ( ! is_file( $source ) && self::can_symlink_site_agents_md( $source ) ) ) {
return array();
}

Expand All @@ -1087,17 +1090,30 @@ private static function project_site_agents_md( string $worktree_path, array $pa
}
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.symlink_symlink -- Local checkout projection to a DMC-owned generated file.
if ( ! symlink( $source, $target ) ) {
return new \WP_Error(
'agents_md_projection_failed',
sprintf( 'Failed to symlink site AGENTS.md into worktree: %s', $target ),
array( 'status' => 500 )
);
$projection_kind = 'symlink';
if ( self::can_symlink_site_agents_md( $source ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.symlink_symlink -- Local checkout projection to a DMC-owned generated file.
if ( ! symlink( $source, $target ) ) {
return new \WP_Error(
'agents_md_projection_failed',
sprintf( 'Failed to symlink site AGENTS.md into worktree: %s', $target ),
array( 'status' => 500 )
);
}
} else {
$projection_kind = 'inline';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
if ( false === file_put_contents( $target, self::render( $payload ) ) ) {
return new \WP_Error(
'agents_md_projection_failed',
sprintf( 'Failed to write inline site AGENTS.md into worktree: %s', $target ),
array( 'status' => 500 )
);
}
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
if ( false === file_put_contents( $marker, $source . "\n" ) ) {
if ( false === file_put_contents( $marker, $projection_kind . "\n" . $source . "\n" ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Roll back the symlink if the ownership marker cannot be written.
unlink( $target );
return new \WP_Error(
Expand All @@ -1110,6 +1126,20 @@ private static function project_site_agents_md( string $worktree_path, array $pa
return array( $target, $marker );
}

/**
* Determine whether the site AGENTS.md path can safely be symlinked into a host checkout.
*
* Studio's WP-CLI runtime exposes the mounted WordPress tree as `/wordpress` inside
* PHP-WASM. Symlinking that virtual path into `/Users/.../Developer` creates a
* broken host symlink and can crash the Studio CLI while syncing filesystem state.
*
* @param string $source Absolute AGENTS.md source path as seen by PHP.
* @return bool Whether a host-visible symlink should be used.
*/
private static function can_symlink_site_agents_md( string $source ): bool {
return ! str_starts_with( $source, '/wordpress/' );
}

/**
* Add the site AGENTS.md to a local OpenCode instructions array.
*
Expand All @@ -1124,8 +1154,8 @@ private static function project_site_agents_md_via_opencode_config( string $work

$config_exists = is_file( $config );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Path is a marker file within a controlled worktree.
$previous = $config_exists ? (string) file_get_contents( $config ) : '';
$data = array(
$previous = $config_exists ? (string) file_get_contents( $config ) : '';
$data = array(
'$schema' => 'https://opencode.ai/config.json',
'instructions' => array(),
);
Expand Down Expand Up @@ -1205,7 +1235,15 @@ public static function uninject( string $worktree_path ): array {
$projected_agents = rtrim( $worktree_path, '/' ) . '/' . self::PROJECTED_AGENTS_PATH;
$projection_marker = rtrim( $worktree_path, '/' ) . '/' . self::PROJECTED_AGENTS_MARKER_PATH;
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Path is a marker file within a controlled worktree.
$marked_source = is_file( $projection_marker ) ? trim( (string) file_get_contents( $projection_marker ) ) : '';
$marked_source = '';
$projection_kind = 'symlink';
if ( is_file( $projection_marker ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Path is a marker file within a controlled worktree.
$marker_lines = preg_split( '/\r\n|\r|\n/', trim( (string) file_get_contents( $projection_marker ) ) );
$marker_lines = false === $marker_lines ? array() : $marker_lines;
$projection_kind = in_array( $marker_lines[0] ?? '', array( 'symlink', 'inline' ), true ) ? (string) $marker_lines[0] : 'symlink';
$marked_source = 'symlink' === $projection_kind && isset( $marker_lines[1] ) ? (string) $marker_lines[1] : (string) ( $marker_lines[0] ?? '' );
}
if (
is_link( $projected_agents ) &&
'' !== $marked_source &&
Expand All @@ -1214,6 +1252,10 @@ public static function uninject( string $worktree_path ): array {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only context symlink from a worktree.
unlink( $projected_agents );
$removed[] = $projected_agents;
} elseif ( 'inline' === $projection_kind && is_file( $projected_agents ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only inline context from a worktree.
unlink( $projected_agents );
$removed[] = $projected_agents;
}
if ( is_file( $projection_marker ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only projection marker from a worktree.
Expand Down Expand Up @@ -1343,8 +1385,8 @@ private static function resolve_origin_agent(): ?string {

try {
$dm = new \DataMachine\Core\FilesRepository\DirectoryManager();
$user_id = method_exists( $dm, 'get_effective_user_id' ) ? $dm->get_effective_user_id( 0 ) : 0;
$agent_slug = method_exists( $dm, 'resolve_agent_slug' ) ? $dm->resolve_agent_slug( array( 'user_id' => $user_id ) ) : '';
$user_id = $dm->get_effective_user_id( 0 );
$agent_slug = $dm->resolve_agent_slug( array( 'user_id' => $user_id ) );
return '' !== (string) $agent_slug ? (string) $agent_slug : null;
} catch ( \Throwable $e ) {
return null;
Expand Down
29 changes: 28 additions & 1 deletion tests/smoke-worktree-context-injection.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,15 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
$site_root = $tmp_root . '/site';
$worktree_root = $tmp_root . '/worktree';
$existing_root = $tmp_root . '/existing-worktree';
$virtual_root = $tmp_root . '/virtual-worktree';
$source_agents = $site_root . '/AGENTS.md';
$worktree_agents = $worktree_root . '/AGENTS.md';
$virtual_agents = $virtual_root . '/AGENTS.md';

mkdir( $site_root, 0777, true );
mkdir( $worktree_root, 0777, true );
mkdir( $existing_root, 0777, true );
mkdir( $virtual_root, 0777, true );
file_put_contents( $source_agents, "# Site AGENTS\n" );
file_put_contents( $existing_root . '/AGENTS.md', "# Repo AGENTS\n" );

Expand All @@ -141,11 +144,27 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
datamachine_code_context_assert( is_link( $worktree_agents ), 'root AGENTS.md projection is a symlink' );
datamachine_code_context_assert( readlink( $worktree_agents ) === $source_agents, 'root AGENTS.md points at site AGENTS.md' );
datamachine_code_context_assert( in_array( $worktree_agents, $injection['written'], true ), 'projected AGENTS.md is reported as written' );
datamachine_code_context_assert( trim( file_get_contents( $worktree_root . '/.datamachine/AGENTS.md.source' ) ) === $source_agents, 'projection marker records site AGENTS.md source' );
datamachine_code_context_assert( trim( file_get_contents( $worktree_root . '/.datamachine/AGENTS.md.source' ) ) === "symlink\n" . $source_agents, 'projection marker records symlink source' );
datamachine_code_context_assert( ! file_exists( $worktree_root . '/.opencode/AGENTS.local.md' ), 'fake OpenCode local snapshot is not written' );
mkdir( $worktree_root . '/.opencode', 0777, true );
file_put_contents( $worktree_root . '/.opencode/AGENTS.local.md', "# Legacy\n" );

$virtual_injection = \DataMachineCode\Workspace\WorktreeContextInjector::inject(
$virtual_root,
array(
'site_name' => 'Virtual Site',
'agents_md_path' => '/wordpress/AGENTS.md',
'files' => array(
'MEMORY.md' => "# Virtual Memory\n",
),
)
);
datamachine_code_context_assert( ! is_wp_error( $virtual_injection ), 'virtual context injection succeeds' );
datamachine_code_context_assert( ! is_link( $virtual_agents ), 'virtual AGENTS.md projection is not a host symlink' );
datamachine_code_context_assert( is_file( $virtual_agents ), 'virtual AGENTS.md projection is written inline' );
datamachine_code_context_assert( str_contains( (string) file_get_contents( $virtual_agents ), '# Virtual Memory' ), 'inline virtual projection contains rendered context' );
datamachine_code_context_assert( trim( file_get_contents( $virtual_root . '/.datamachine/AGENTS.md.source' ) ) === "inline\n/wordpress/AGENTS.md", 'virtual projection marker records inline source' );

$existing_injection = \DataMachineCode\Workspace\WorktreeContextInjector::inject(
$existing_root,
array(
Expand All @@ -165,6 +184,9 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
datamachine_code_context_assert( ! file_exists( $worktree_agents ) && ! is_link( $worktree_agents ), 'projected AGENTS.md is gone after uninject' );
datamachine_code_context_assert( ! file_exists( $worktree_root . '/.datamachine/AGENTS.md.source' ), 'projection marker is gone after uninject' );
datamachine_code_context_assert( ! file_exists( $worktree_root . '/.opencode/AGENTS.local.md' ), 'uninject removes legacy fake OpenCode local snapshot' );
$virtual_removed = \DataMachineCode\Workspace\WorktreeContextInjector::uninject( $virtual_root );
datamachine_code_context_assert( in_array( $virtual_agents, $virtual_removed['removed'], true ), 'uninject removes inline virtual AGENTS.md projection' );
datamachine_code_context_assert( ! file_exists( $virtual_agents ), 'inline virtual projection is gone after uninject' );
$existing_removed = \DataMachineCode\Workspace\WorktreeContextInjector::uninject( $existing_root );
datamachine_code_context_assert( ! file_exists( $existing_root . '/.opencode/opencode.json' ), 'uninject removes DMC-created OpenCode projection config' );
datamachine_code_context_assert( in_array( $existing_root . '/.opencode/opencode.json', $existing_removed['removed'], true ), 'removed OpenCode projection config is reported' );
Expand All @@ -173,8 +195,10 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
array_map( 'unlink', glob( $worktree_root . '/.opencode/*' ) ?: array() );
array_map( 'unlink', glob( $existing_root . '/.claude/*' ) ?: array() );
array_map( 'unlink', glob( $existing_root . '/.opencode/*' ) ?: array() );
array_map( 'unlink', glob( $virtual_root . '/.claude/*' ) ?: array() );
array_map( 'rmdir', array_filter( glob( $worktree_root . '/*' ) ?: array(), 'is_dir' ) );
array_map( 'rmdir', array_filter( glob( $existing_root . '/*' ) ?: array(), 'is_dir' ) );
array_map( 'rmdir', array_filter( glob( $virtual_root . '/*' ) ?: array(), 'is_dir' ) );
unlink( $source_agents );
unlink( $existing_root . '/AGENTS.md' );
rmdir( $worktree_root . '/.claude' );
Expand All @@ -183,8 +207,11 @@ function datamachine_code_pos( string $haystack, string $needle, string $label )
rmdir( $existing_root . '/.claude' );
rmdir( $existing_root . '/.opencode' );
rmdir( $existing_root . '/.datamachine' );
rmdir( $virtual_root . '/.claude' );
rmdir( $virtual_root . '/.datamachine' );
rmdir( $worktree_root );
rmdir( $existing_root );
rmdir( $virtual_root );
rmdir( $site_root );
rmdir( $tmp_root );

Expand Down
Loading