Skip to content
Open
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: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ wp datamachine-code workspace worktree list
wp datamachine-code workspace worktree remove repo-name fix/foo
wp datamachine-code workspace worktree prune

# Typed Homeboy promotion apply (explicit handle supports {handle} argv substitution)
wp datamachine-code workspace promotion-apply repo-name@fix-foo < promotion-request.json

# Daily workspace cleanup — task-backed, inspectable by run_id
wp datamachine-code workspace cleanup run --mode=retention
wp datamachine-code workspace cleanup status cleanup-run-123
Expand All @@ -111,6 +114,11 @@ wp datamachine-code workspace git log repo-name@fix-foo
wp datamachine-code workspace git diff repo-name@fix-foo
```

`promotion-apply` reads one `homeboy/agent-task-promotion-apply-request/v1`
object from stdin and emits `homeboy/agent-task-promotion-apply-response/v1` on
stdout. It only applies through the managed worktree patch primitive and refuses
primary, dirty, mismatched, and untrusted-unpushed targets.

### Worktrees: parallel-safe branch work

The workspace is **worktree-native**. Each branch lives in its own directory at
Expand Down
45 changes: 45 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use DataMachine\Cli\BaseCommand;
use DataMachineCode\Cli\CliResponseRenderer;
use DataMachineCode\Cli\CliRepeatableOptionParser;
use DataMachineCode\Cli\HomeboyPromotionApplyAdapter;
use DataMachineCode\Cli\WorkspaceCompactOutput;
use DataMachineCode\Cleanup\CompositeCleanupRunEvidenceStore;
use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface;
Expand Down Expand Up @@ -3101,6 +3102,50 @@ public function patch( array $args, array $assoc_args ): void {
}
}

/**
* Apply a typed Homeboy promotion request to one managed worktree.
*
* Reads exactly one `homeboy/agent-task-promotion-apply-request/v1` JSON
* object from stdin and writes its response object to stdout. The explicit
* handle is intended for Homeboy's `{handle}` argv substitution.
*
* ## OPTIONS
*
* <handle>
* : Exact DMC-managed non-primary worktree handle.
*
* ## EXAMPLES
*
* wp datamachine-code workspace promotion-apply data-machine-code@feat-945 < request.json
*
* @subcommand promotion-apply
*/
public function promotion_apply( array $args, array $assoc_args ): void {
$handle = (string) ( $args[0] ?? '' );
if ( '' === trim($handle) || isset($args[1]) ) {
WP_CLI::error('Usage: wp datamachine-code workspace promotion-apply <handle> < request.json');
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$input = file_get_contents('php://stdin');
if ( false === $input ) {
WP_CLI::error('Failed to read promotion request from stdin.');
return;
}
$request = json_decode($input, true);
if ( ! is_array($request) || JSON_ERROR_NONE !== json_last_error() ) {
WP_CLI::error('Promotion request stdin must contain one JSON object.');
return;
}

$result = HomeboyPromotionApplyAdapter::create()->execute($handle, $request);
if ( is_wp_error($result) ) {
WP_CLI::error($result->get_error_code() . ': ' . $result->get_error_message());
return;
}
$this->renderer()->json($result);
}

/**
* Delete a tracked or untracked path from a workspace repo.
*
Expand Down
159 changes: 159 additions & 0 deletions inc/Cli/HomeboyPromotionApplyAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php
/**
* Typed Homeboy promotion patch adapter.
*
* @package DataMachineCode\Cli
*/

namespace DataMachineCode\Cli;

use DataMachineCode\Workspace\Workspace;
use DataMachineCode\Workspace\WorkspaceWriter;

defined('ABSPATH') || exit;

final class HomeboyPromotionApplyAdapter {

private const REQUEST_SCHEMA = 'homeboy/agent-task-promotion-apply-request/v1';
private const RESPONSE_SCHEMA = 'homeboy/agent-task-promotion-apply-response/v1';
private const EVIDENCE_LIMIT = 1024;

public function __construct( private object $workspace, private object $writer ) {
}

public static function create(): self {
$workspace = new Workspace();
return new self($workspace, new WorkspaceWriter($workspace));
}

/**
* @param array<string,mixed> $request
* @return array<string,mixed>|\WP_Error
*/
public function execute( string $expected_handle, array $request ): array|\WP_Error {
$expected_handle = trim($expected_handle);
if ( '' === $expected_handle ) {
return $this->error('missing_expected_handle', 'The expected managed worktree handle is required.');
}
if ( self::REQUEST_SCHEMA !== ( $request['schema'] ?? null ) ) {
return $this->error('invalid_promotion_schema', 'Request schema must be ' . self::REQUEST_SCHEMA . '.');
}
if ( $expected_handle !== ( $request['to_workspace'] ?? null ) ) {
return $this->error('promotion_handle_mismatch', 'Request to_workspace must exactly match the expected handle.');
}
if ( ! is_string($request['patch_path'] ?? null) || '' === trim($request['patch_path']) ) {
return $this->error('invalid_patch_path', 'Request patch_path must be a non-empty string.');
}
if ( ! is_array($request['changed_files'] ?? null) || empty($request['changed_files']) || array_filter($request['changed_files'], static fn( $path ): bool => ! is_string($path) || '' === trim($path)) ) {
return $this->error('invalid_changed_files', 'Request changed_files must be a non-empty list of paths.');
}
if ( ! is_bool($request['dry_run'] ?? null) ) {
return $this->error('invalid_dry_run', 'Request dry_run must be a boolean.');
}

$target = $this->resolve_target($expected_handle, $request);
if ( is_wp_error($target) ) {
return $target;
}
$patch = $this->resolve_patch($request);
if ( is_wp_error($patch) ) {
return $patch;
}

$preflight = $this->writer->apply_patch($expected_handle, $patch, false, true);
if ( is_wp_error($preflight) ) {
return $preflight;
}
$expected_files = $this->normalized_paths($request['changed_files']);
$actual_files = $this->normalized_paths((array) ( $preflight['changed_files'] ?? array() ));
if ( $expected_files !== $actual_files ) {
return $this->error('changed_files_mismatch', 'Request changed_files must exactly match the patch paths.');
}
$result = $preflight;
if ( ! $request['dry_run'] ) {
$result = $this->writer->apply_patch($expected_handle, $patch);
if ( is_wp_error($result) ) {
return $result;
}
}

return array(
'schema' => self::RESPONSE_SCHEMA,
'workspace_path' => (string) $target['path'],
'command_evidence' => $this->evidence((bool) $request['dry_run'], (string) ( $result['check_output'] ?? '' ), (string) ( $result['apply_output'] ?? '' )),
);
}

/** @return array<string,mixed>|\WP_Error */
private function resolve_target( string $handle, array $request ): array|\WP_Error {
$parsed = $this->workspace->parse_handle($handle);
if ( empty($parsed['is_worktree']) ) {
return $this->error('primary_worktree', 'Promotion apply only permits managed non-primary worktrees.');
}
$target = $this->workspace->managed_worktree_get($handle);
if ( is_wp_error($target) ) {
return $target;
}
if ( $handle !== ( $target['handle'] ?? null ) || empty($target['is_worktree']) || ! empty($target['is_primary']) || ! empty($target['external']) || ! is_string($target['path'] ?? null) ) {
return $this->error('unmanaged_worktree', 'The requested handle is not an exact DMC-managed worktree registry entry.');
}
if ( 0 !== (int) ( $target['dirty'] ?? -1 ) ) {
return $this->error('dirty_worktree', 'Promotion apply refuses a dirty destination worktree.');
}
if ( (int) ( $target['unpushed'] ?? 0 ) > 0 && ! $this->is_trusted_unpushed_target($target, $request['trusted_unpushed_candidate_destination'] ?? null) ) {
return $this->error('untrusted_unpushed_target', 'Promotion apply refuses unpushed commits unless the request attests to this exact destination path and HEAD.');
}

return $target;
}

/** @param array<string,mixed> $target */
private function is_trusted_unpushed_target( array $target, mixed $candidate ): bool {
return is_array($candidate)
&& ( $target['path'] ?? null ) === ( $candidate['path'] ?? null )
&& ( $target['head'] ?? null ) === ( $candidate['head'] ?? null );
}

/** @param array<string,mixed> $request */
private function resolve_patch( array $request ): string|\WP_Error {
if ( isset($request['patch']) ) {
if ( ! is_string($request['patch']) || '' === trim($request['patch']) ) {
return $this->error('invalid_patch', 'Request patch must be a non-empty unified diff when supplied.');
}
return $request['patch'];
}
$patch_path = realpath($request['patch_path']);
if ( false === $patch_path || ! is_file($patch_path) || ! is_readable($patch_path) ) {
return $this->error('invalid_patch_path', 'Request patch_path must resolve to a readable regular file when patch is omitted.');
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$patch = file_get_contents($patch_path);
return false === $patch || '' === trim($patch) ? $this->error('invalid_patch_path', 'Request patch_path did not contain a patch.') : $patch;
}

/** @param array<int,mixed> $paths @return array<int,string> */
private function normalized_paths( array $paths ): array {
$paths = array_map(static fn( $path ): string => ltrim(str_replace('\\', '/', trim((string) $path)), '/'), $paths);
$paths = array_values(array_unique(array_filter($paths)));
sort($paths);
return $paths;
}

/** @return array<int,array<string,mixed>> */
private function evidence( bool $dry_run, string $check_output, string $apply_output ): array {
$evidence = array( $this->command_evidence(array( 'git', 'apply', '--check', '--whitespace=nowarn', '<patch>' ), $check_output) );
if ( ! $dry_run ) {
$evidence[] = $this->command_evidence(array( 'git', 'apply', '--whitespace=nowarn', '<patch>' ), $apply_output);
}
return $evidence;
}

/** @param array<int,string> $command @return array<string,mixed> */
private function command_evidence( array $command, string $stdout ): array {
return array( 'command' => $command, 'exit_code' => 0, 'stdout' => substr($stdout, 0, self::EVIDENCE_LIMIT), 'stderr' => '' );
}

private function error( string $code, string $message ): \WP_Error {
return new \WP_Error($code, $message, array( 'status' => 400 ));
}
}
36 changes: 36 additions & 0 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,42 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
);
}

/**
* Resolve a live worktree only when it has the matching DMC inventory row.
*
* This is the ownership boundary for external callers that must not operate
* on a worktree created outside DMC, even when it happens to sit below the
* configured workspace root.
*
* @return array<string,mixed>|\WP_Error
*/
public function managed_worktree_get( string $handle ): array|\WP_Error {
$parsed = $this->parse_handle($handle);
if ( ! $parsed['is_worktree'] || $handle !== $parsed['dir_name'] ) {
return new \WP_Error('unmanaged_worktree', 'The requested handle is not a managed non-primary worktree.', array( 'status' => 403 ));
}
$stored = $this->worktree_inventory()->get($handle);
if ( ! is_array($stored) || ! empty($stored['missing_path']) || $handle !== ( $stored['handle'] ?? null ) || ! empty($stored['is_primary']) ) {
return new \WP_Error('unmanaged_worktree', 'The requested handle has no active DMC worktree registry entry.', array( 'status' => 403 ));
}
$listing = $this->worktree_list((string) $parsed['repo'], null, array( 'handle' => $handle, 'include_status' => true, 'include_disk' => false ));
if ( is_wp_error($listing) ) {
return $listing;
}
$rows = (array) ( $listing['worktrees'] ?? array() );
if ( 1 !== count($rows) || ! is_array($rows[0]) ) {
return new \WP_Error('unmanaged_worktree', 'The requested handle is not a live DMC-managed worktree.', array( 'status' => 403 ));
}
$row = $rows[0];
$stored_path = realpath((string) ( $stored['path'] ?? '' ));
$live_path = realpath((string) ( $row['path'] ?? '' ));
if ( $handle !== ( $row['handle'] ?? null ) || empty($row['is_worktree']) || ! empty($row['is_primary']) || ! empty($row['external']) || false === $stored_path || false === $live_path || $stored_path !== $live_path ) {
return new \WP_Error('unmanaged_worktree', 'The requested worktree does not match its DMC registry path.', array( 'status' => 403 ));
}
$row['path'] = $live_path;
return $row;
}

/**
* Return warning metadata when a non-primary worktree holds a base branch.
*
Expand Down
19 changes: 17 additions & 2 deletions inc/Workspace/WorkspaceWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,10 @@ public function edit_file( string $name, string $path, string $old_string, strin
* @param string $name Workspace handle.
* @param string $patch Unified diff to apply.
* @param bool $allow_primary_mutation Permit mutation on a primary checkout.
* @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string}|\WP_Error
* @param bool $dry_run Check the patch without applying it.
* @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string, dry_run?: bool}|\WP_Error
*/
public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false ): array|\WP_Error {
public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false, bool $dry_run = false ): array|\WP_Error {
$mutation_check = $this->ensure_workspace_mutation_allowed($name, 'apply-patch', $allow_primary_mutation);
if ( is_wp_error($mutation_check) ) {
return $mutation_check;
Expand Down Expand Up @@ -305,6 +306,20 @@ public function apply_patch( string $name, string $patch, bool $allow_primary_mu
);
}

if ( $dry_run ) {
return array(
'success' => true,
'name' => $name,
'path' => $repo_path,
'changed_files' => $patch_paths,
'diff' => '',
'status' => '',
'check_output' => $check['output'],
'apply_output' => '',
'dry_run' => true,
);
}

$apply = GitRunner::run($repo_path, 'apply --whitespace=nowarn ' . $patch_arg);
if ( is_wp_error($apply) ) {
return new \WP_Error(
Expand Down
Loading