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
7 changes: 6 additions & 1 deletion inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2568,6 +2568,10 @@ private function registerAbilities(): void {
'type' => 'boolean',
'description' => 'Explicitly allow active artifact eviction after reviewing live/process/unavailable evidence. Separate from force and must be repeated at DB-backed apply/resume.',
),
'allow_unavailable_process_probe' => array(
'type' => 'boolean',
'description' => 'Explicitly allow cleanup when process-path inspection is unavailable or uncertain. This does not waive authoritative live-owner or active-process evidence.',
),
'apply_plan' => array(
'type' => 'object',
'description' => 'Decoded artifact cleanup dry-run report to apply after revalidating every worktree and artifact path.',
Expand Down Expand Up @@ -4678,7 +4682,7 @@ public static function worktreeAbandonedCleanup( array $input ): array|\WP_Error
* Remove profile-derived artifacts inside workspace worktrees.
*
* @param array $input Input parameters (dry_run, force,
* allow_active_artifact_cleanup, apply_plan, limit,
* allow_active_artifact_cleanup, allow_unavailable_process_probe, apply_plan, limit,
* offset, exhaustive, safety_probes).
* @return array
*/
Expand All @@ -4688,6 +4692,7 @@ public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error
'dry_run' => ! empty($input['dry_run']),
'force' => ! empty($input['force']),
'allow_active_artifact_cleanup' => ! empty($input['allow_active_artifact_cleanup']),
'allow_unavailable_process_probe' => ! empty($input['allow_unavailable_process_probe']),
);
if ( isset($input['apply_plan']) && is_array($input['apply_plan']) ) {
$opts['apply_plan'] = $input['apply_plan'];
Expand Down
1 change: 1 addition & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4328,6 +4328,7 @@ public function worktree( array $args, array $assoc_args ): void {
$input['dry_run'] = ! empty($assoc_args['dry-run']);
$input['force'] = ! empty($assoc_args['force']);
$input['allow_active_artifact_cleanup'] = ! empty($assoc_args['allow-active-artifact-cleanup']);
$input['allow_unavailable_process_probe'] = ! empty($assoc_args['allow-unavailable-process-probe']);
if ( isset($assoc_args['limit']) ) {
$input['limit'] = (int) $assoc_args['limit'];
}
Expand Down
103 changes: 103 additions & 0 deletions inc/Support/ProcessPathProbe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/**
* Portable process path probes.
*
* @package DataMachineCode\Support
*/

namespace DataMachineCode\Support;

defined('ABSPATH') || exit;

if ( ! class_exists(CommandSpec::class) ) {
require_once __DIR__ . '/CommandSpec.php';
}
if ( ! class_exists(ProcessRunner::class) ) {
require_once __DIR__ . '/ProcessRunner.php';
}

interface ProcessPathProbeInterface {

/** @return array{status:string,records:array<int,array<string,mixed>>,diagnostics:array<string,mixed>} */
public function snapshot(): array;
}

final class ProcfsProcessPathProbe implements ProcessPathProbeInterface {

/** @param callable $scanner Returns a procfs snapshot. */
public function __construct(private $scanner) {}

public function snapshot(): array {
return ($this->scanner)();
}
}

final class UnsupportedProcessPathProbe implements ProcessPathProbeInterface {

public function __construct(private string $platform) {}

public function snapshot(): array {
return array(
'status' => 'unsupported',
'records' => array(),
'diagnostics' => array( 'reason' => 'process_path_probe_unsupported', 'platform' => $this->platform, 'remediation' => 'Use a supported host process-path provider before artifact cleanup.' ),
);
}
}

final class MacOSLsofProcessPathProbe implements ProcessPathProbeInterface {

/** @param callable|null $runner Receives argv and returns a ProcessRunner envelope. */
public function __construct(private $runner = null) {}

public function snapshot(): array {
$argv = array( 'lsof', '-n', '-P', '-Fpcfn0' );
if ( is_callable($this->runner) ) {
$result = ($this->runner)($argv);
} else {
$command = CommandSpec::from_argv($argv);
$result = $command instanceof \WP_Error ? $command : ProcessRunner::run($command, array( 'timeout_seconds' => 2, 'output_cap_bytes' => 1048576, 'error_as_result' => true ));
}
if ( $result instanceof \WP_Error || ! is_array($result) || empty($result['success']) ) {
$data = $result instanceof \WP_Error ? (array) $result->get_error_data() : (array) $result;
return array(
'status' => 'uncertain',
'records' => array(),
'diagnostics' => array( 'reason' => ! empty($data['timeout']) ? 'process_path_probe_timeout' : 'process_path_probe_failed', 'provider' => 'lsof', 'details' => $data ),
);
}

$records = array();
$pid = 0;
$command = '';
$fd = '';
foreach ( explode("\0", (string) ($result['output'] ?? '')) as $field ) {
$field = ltrim($field, "\n");
if ( '' === $field ) {
continue;
}
$type = $field[0];
$value = substr($field, 1);
if ( 'p' === $type ) {
$pid = ctype_digit($value) ? (int) $value : 0;
} elseif ( 'c' === $type ) {
$command = $value;
} elseif ( 'f' === $type ) {
$fd = $value;
} elseif ( 'n' === $type && $pid > 0 && str_starts_with($value, '/') ) {
$records[] = array(
'pid' => $pid,
'command' => $command,
'match_type' => 'cwd' === $fd ? 'cwd' : 'open_file',
'path' => preg_replace('/ \(deleted\)$/', '', $value),
) + ( 'cwd' === $fd ? array() : array( 'fd' => $fd ) );
}
}

return array(
'status' => 'available',
'records' => $records,
'diagnostics' => array( 'provider' => 'lsof', 'path_records' => count($records) ),
);
}
}
52 changes: 43 additions & 9 deletions inc/Workspace/WorkspaceArtifactCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@

namespace DataMachineCode\Workspace;

use DataMachineCode\Support\MacOSLsofProcessPathProbe;
use DataMachineCode\Support\ProcfsProcessPathProbe;
use DataMachineCode\Support\ProcessPathProbeInterface;
use DataMachineCode\Support\UnsupportedProcessPathProbe;

defined('ABSPATH') || exit;

require_once __DIR__ . '/WorktreeContextInjector.php';
require_once __DIR__ . '/WorktreeAgeFilter.php';
require_once dirname(__DIR__) . '/Support/ProcessPathProbe.php';

trait WorkspaceArtifactCleanup {

Expand All @@ -35,14 +41,15 @@ trait WorkspaceArtifactCleanup {
* planned worktrees rather than the entire workspace.
*
* @param array $opts Cleanup options (dry_run, force,
* allow_active_artifact_cleanup, apply_plan, limit,
* allow_active_artifact_cleanup, allow_unavailable_process_probe, apply_plan, limit,
* offset, exhaustive, safety_probes, older_than).
* @return array<string,mixed>|\WP_Error
*/
public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_Error {
$dry_run = ! empty($opts['dry_run']);
$force = ! empty($opts['force']);
$allow_active = ! empty($opts['allow_active_artifact_cleanup']);
$allow_unavailable_process_probe = ! empty($opts['allow_unavailable_process_probe']);
$apply_plan = isset($opts['apply_plan']) && is_array($opts['apply_plan']) ? $opts['apply_plan'] : null;
$older_than = isset($opts['older_than']) ? trim( (string) $opts['older_than']) : '';
if ( '' === $older_than && null !== $apply_plan && is_array($apply_plan['age_filter'] ?? null) ) {
Expand All @@ -66,7 +73,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
$limit = 0;
}
$review_command = $this->build_artifact_cleanup_review_command($opts);
$apply_command = $this->build_artifact_cleanup_apply_command($force, $allow_active);
$apply_command = $this->build_artifact_cleanup_apply_command($force, $allow_active, $allow_unavailable_process_probe);
$preview_command = $this->build_artifact_cleanup_preview_command($opts);
// Apply paths default to safety probing (small subset). Dry-run defaults
// to skipping the per-worktree git probes unless explicitly requested or
Expand Down Expand Up @@ -107,6 +114,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
$force,
array(
'allow_active_artifact_cleanup' => $allow_active,
'allow_unavailable_process_probe' => $allow_unavailable_process_probe,
'limit' => $plan_limit,
'offset' => $rank_by_size ? 0 : $offset,
'only_handles' => $only_handles,
Expand Down Expand Up @@ -291,10 +299,11 @@ private function build_artifact_cleanup_review_command( array $opts = array() ):
*
* @return string
*/
private function build_artifact_cleanup_apply_command( bool $force = false, bool $allow_active = false ): string {
private function build_artifact_cleanup_apply_command( bool $force = false, bool $allow_active = false, bool $allow_unavailable_process_probe = false ): string {
return 'studio wp datamachine-code workspace cleanup apply <run-id>'
. ( $force ? ' --force' : '' )
. ( $allow_active ? ' --allow-active-artifact-cleanup' : '' );
. ( $allow_active ? ' --allow-active-artifact-cleanup' : '' )
. ( $allow_unavailable_process_probe ? ' --allow-unavailable-process-probe' : '' );
}

/**
Expand All @@ -311,6 +320,9 @@ private function build_artifact_cleanup_preview_command( array $opts ): string {
if ( ! empty($opts['allow_active_artifact_cleanup']) ) {
$parts[] = '--allow-active-artifact-cleanup';
}
if ( ! empty($opts['allow_unavailable_process_probe']) ) {
$parts[] = '--allow-unavailable-process-probe';
}
if ( isset($opts['limit']) ) {
$parts[] = '--limit=' . (int) $opts['limit'];
}
Expand Down Expand Up @@ -361,6 +373,7 @@ private function build_worktree_artifact_cleanup_plan( bool $force, array $opts
$limit = isset($opts['limit']) ? (int) $opts['limit'] : 0;
$offset = isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0;
$allow_active = ! empty($opts['allow_active_artifact_cleanup']);
$allow_unavailable_process_probe = ! empty($opts['allow_unavailable_process_probe']);
$only_handles = isset($opts['only_handles']) && is_array($opts['only_handles'])
? array_values(array_filter(array_map('strval', $opts['only_handles']), fn( $h ) => '' !== $h))
: null;
Expand Down Expand Up @@ -512,7 +525,8 @@ private function build_worktree_artifact_cleanup_plan( bool $force, array $opts

$process_protection = $this->active_artifact_process_protection($wt_path, $artifacts);
if ( null !== $process_protection ) {
if ( ! $allow_active ) {
$is_probe_unavailable = str_starts_with((string) ($process_protection['reason_code'] ?? ''), 'active_process_probe_');
if ( ( $is_probe_unavailable && ! $allow_unavailable_process_probe ) || ( ! $is_probe_unavailable && ! $allow_active ) ) {
$skipped[] = array_merge($base_row, $process_protection, array( 'artifacts' => $artifacts ));
continue;
}
Expand Down Expand Up @@ -726,7 +740,7 @@ private function active_artifact_process_protection( string $worktree_path, arra
}

/**
* Conservatively inspect Linux process cwd/open-file paths without naming runtimes or tools.
* Conservatively inspect process cwd/open-file paths through the host provider.
*
* @param string $worktree_path Worktree root.
* @param array<int,array> $artifacts Profile-derived artifact rows.
Expand Down Expand Up @@ -785,6 +799,15 @@ protected function artifact_process_path_records( bool $fresh ): array {
return $this->artifact_process_path_snapshot;
}

$result = $this->artifact_process_path_probe()->snapshot();
if ( ! $fresh ) {
$this->artifact_process_path_snapshot = $result;
}
return $result;
}

/** @return array{status:string,records:array<int,array<string,mixed>>,diagnostics:array<string,mixed>} */
private function artifact_procfs_process_path_records(): array {
$proc_root = $this->artifact_process_root();
if ( ! is_dir($proc_root) || ! is_readable($proc_root) ) {
return array(
Expand Down Expand Up @@ -884,9 +907,6 @@ protected function artifact_process_path_records( bool $fresh ): array {
'unknown_mount_namespaces' => array_slice($unknown_namespaces, 0, 10),
),
);
if ( ! $fresh ) {
$this->artifact_process_path_snapshot = $result;
}
return $result;
}

Expand All @@ -897,6 +917,20 @@ protected function artifact_process_root(): string {
return '/proc';
}

/** Resolve the host process-path provider. Overridable for deterministic tests. */
protected function artifact_process_path_probe(): ProcessPathProbeInterface {
if ( '/proc' !== $this->artifact_process_root() ) {
return new ProcfsProcessPathProbe(fn() => $this->artifact_procfs_process_path_records());
}
if ( 'Darwin' === PHP_OS_FAMILY ) {
return new MacOSLsofProcessPathProbe();
}
if ( 'Linux' === PHP_OS_FAMILY ) {
return new ProcfsProcessPathProbe(fn() => $this->artifact_procfs_process_path_records());
}
return new UnsupportedProcessPathProbe(PHP_OS_FAMILY);
}

/**
* Preserve measured mutation when a later artifact in the row becomes blocked.
*
Expand Down
42 changes: 30 additions & 12 deletions tests/artifact-cleanup-live-process-guards.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ protected function observe_artifact_reclamation_path( string $worktree_path, str
use DataMachineCode\Workspace\ArtifactCleanupGuardHarness;
use DataMachineCode\Workspace\ControlledArtifactCleanupGuardHarness;
use DataMachineCode\Workspace\WorktreeContextInjector;
use DataMachineCode\Support\MacOSLsofProcessPathProbe;

function artifact_guard_assert_same( mixed $expected, mixed $actual, string $message ): void {
if ( $expected !== $actual ) {
Expand Down Expand Up @@ -220,18 +221,35 @@ function artifact_guard_create_artifacts( string $path, bool $multiple = false )
$forced = $harness->worktree_cleanup_artifacts(array( 'dry_run' => true, 'safety_probes' => true, 'force' => true ));
artifact_guard_assert_same(0, count($forced['candidates']), 'legacy force must not override unavailable active-process evidence');
$active_override = $harness->worktree_cleanup_artifacts(array( 'dry_run' => true, 'safety_probes' => true, 'allow_active_artifact_cleanup' => true ));
artifact_guard_assert_same(1, count($active_override['candidates']), 'distinct active artifact override must permit reviewed eviction');
artifact_guard_assert_same('active_process_probe_unavailable', $active_override['candidates'][0]['safety_overrides'][0]['reason_code'] ?? null, 'active override must retain typed unavailable evidence');

$real_scanner = new ArtifactCleanupGuardHarness($root);
$descriptors = array( 0 => array( 'file', '/dev/null', 'r' ), 1 => array( 'file', '/dev/null', 'w' ), 2 => array( 'file', '/dev/null', 'w' ) );
$process = proc_open(array( '/bin/sleep', '5' ), $descriptors, $pipes, $path);
if ( is_resource($process) ) {
usleep(100000);
$real_probe = $real_scanner->probe_processes($path, array( array( 'path' => 'vendor' ) ));
artifact_guard_assert_same(true, array() !== (array) ( $real_probe['evidence'] ?? array() ), 'real procfs scanner must detect a child process cwd in the worktree');
proc_terminate($process);
proc_close($process);
artifact_guard_assert_same(0, count($active_override['candidates']), 'live-owner eviction override must not waive unavailable process evidence');
$probe_override = $harness->worktree_cleanup_artifacts(array( 'dry_run' => true, 'safety_probes' => true, 'allow_unavailable_process_probe' => true ));
artifact_guard_assert_same(1, count($probe_override['candidates']), 'process-probe override must permit reviewed cleanup without waiving live-owner eviction');
artifact_guard_assert_same('active_process_probe_unavailable', $probe_override['candidates'][0]['safety_overrides'][0]['reason_code'] ?? null, 'probe override must retain typed unavailable evidence');

$mac_no_match = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0f3\0n/tmp/unrelated\0" ));
artifact_guard_assert_same('available', $mac_no_match->snapshot()['status'], 'macOS lsof no-match snapshot must be available');
$mac_cwd = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0fcwd\0n{$path}\0" ));
$mac_cwd_records = $mac_cwd->snapshot()['records'];
artifact_guard_assert_same('cwd', $mac_cwd_records[0]['match_type'] ?? null, 'macOS lsof cwd evidence must retain its match type');
$mac_open_file = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0f12\0n{$path}/vendor/generated.php\0" ));
$mac_open_file_records = $mac_open_file->snapshot()['records'];
artifact_guard_assert_same('open_file', $mac_open_file_records[0]['match_type'] ?? null, 'macOS lsof open-file evidence must retain its match type');
$mac_timeout = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => false, 'timeout' => 2 ));
artifact_guard_assert_same('uncertain', $mac_timeout->snapshot()['status'], 'macOS lsof timeout must fail closed as uncertain');
$mac_exit_race = new MacOSLsofProcessPathProbe(fn( array $argv ) => array( 'success' => true, 'output' => "p42\0ctest\0" ));
artifact_guard_assert_same(array(), $mac_exit_race->snapshot()['records'], 'macOS lsof process-exit races without path records must remain safe no-match evidence');

if ( in_array(PHP_OS_FAMILY, array( 'Linux', 'Darwin' ), true) ) {
$real_scanner = new ArtifactCleanupGuardHarness($root);
$descriptors = array( 0 => array( 'file', '/dev/null', 'r' ), 1 => array( 'file', '/dev/null', 'w' ), 2 => array( 'file', '/dev/null', 'w' ) );
$process = proc_open(array( '/bin/sleep', '5' ), $descriptors, $pipes, $path);
if ( is_resource($process) ) {
usleep(100000);
$real_probe = $real_scanner->probe_processes($path, array( array( 'path' => 'vendor' ) ));
artifact_guard_assert_same(true, array() !== (array) ( $real_probe['evidence'] ?? array() ), 'host process scanner must detect a child process cwd in the worktree');
proc_terminate($process);
proc_close($process);
}
}

$fake_proc = $root . '/proc-fixture';
Expand Down