From 3fc367c47e8340f6bfaf7f77b5c7a942f2be25bf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 28 Jul 2026 20:04:33 -0400 Subject: [PATCH] fix: schedule safe workspace cleanup --- data-machine-code.php | 2 + inc/Tasks/WorkspaceSafeCleanupTask.php | 107 ++++++++++++++++++++++ tests/workspace-safe-cleanup-schedule.php | 85 +++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 inc/Tasks/WorkspaceSafeCleanupTask.php create mode 100644 tests/workspace-safe-cleanup-schedule.php diff --git a/data-machine-code.php b/data-machine-code.php index eb547cb8..7de981d8 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -401,6 +401,7 @@ function datamachine_code_load_chat_tools() { $tasks['worktree_cleanup'] = \DataMachineCode\Tasks\WorktreeCleanupTask::class; $tasks['workspace_disk_emergency_cleanup'] = \DataMachineCode\Tasks\WorkspaceDiskEmergencyCleanupTask::class; $tasks['workspace_retention_cleanup'] = \DataMachineCode\Tasks\WorkspaceRetentionCleanupTask::class; + $tasks['workspace_safe_cleanup'] = \DataMachineCode\Tasks\WorkspaceSafeCleanupTask::class; $tasks['workspace_hygiene_report'] = \DataMachineCode\Tasks\WorkspaceHygieneReportTask::class; return $tasks; } @@ -452,6 +453,7 @@ function datamachine_code_load_chat_tools() { 'artifact_chunk_size' => 10, ), ); + $schedules['workspace_safe_cleanup'] = \DataMachineCode\Tasks\WorkspaceSafeCleanupTask::recurringSchedule(); $schedules['workspace_hygiene_report'] = array( 'task_type' => 'workspace_hygiene_report', 'interval' => 'weekly', diff --git a/inc/Tasks/WorkspaceSafeCleanupTask.php b/inc/Tasks/WorkspaceSafeCleanupTask.php new file mode 100644 index 00000000..c6961167 --- /dev/null +++ b/inc/Tasks/WorkspaceSafeCleanupTask.php @@ -0,0 +1,107 @@ + */ + public static function getTaskMeta(): array { + return array( + 'label' => 'Safe Workspace Cleanup', + 'description' => 'Bounded recurring cleanup of DMC workspaces. Revalidates every candidate and preserves dirty, unpushed, and live worktrees.', + 'setting_key' => self::SETTING_KEY, + 'default_enabled' => true, + 'supports_run' => true, + ); + } + + /** @return array */ + public static function recurringSchedule(): array { + return array( + 'task_type' => 'workspace_safe_cleanup', + 'interval' => 'hourly', + 'enabled_setting' => self::SETTING_KEY, + 'default_enabled' => true, + 'label' => 'Hourly — applies bounded safe workspace cleanup', + 'task_params' => array( + 'source' => 'recurring_schedule', + 'limit' => 25, + 'passes' => 5, + 'cycles' => 5, + 'until_budget' => '45s', + ), + ); + } + + /** + * Execute the canonical safe cleanup flow. + * + * Schedule parameters bound execution only. Force and unpushed-discard are + * intentionally not forwarded, so maintenance cannot weaken safety policy. + * + * @param int $jobId Job ID. + * @param array $params Task parameters. + */ + public function executeTask( int $jobId, array $params ): void { + if ( ! PluginSettings::get(self::SETTING_KEY, true) ) { + $this->completeJob( + $jobId, + array( + 'skipped' => true, + 'reason' => sprintf('Safe workspace cleanup disabled (PluginSettings: %s=false).', self::SETTING_KEY), + ) + ); + return; + } + + $input = array( + 'source' => (string) ( $params['source'] ?? 'system_task' ), + 'limit' => isset($params['limit']) ? (int) $params['limit'] : 25, + 'passes' => isset($params['passes']) ? (int) $params['passes'] : 5, + 'cycles' => isset($params['cycles']) ? (int) $params['cycles'] : 5, + 'until_budget' => isset($params['until_budget']) ? (string) $params['until_budget'] : '45s', + 'dry_run' => ! empty($params['dry_run']), + 'force' => false, + 'discard_unpushed' => false, + ); + $result = $this->run_safe_cleanup($input); + + if ( $result instanceof \WP_Error ) { + do_action('datamachine_log', 'error', 'Safe workspace cleanup failed', array( 'task' => $this->getTaskType(), 'jobId' => $jobId, 'error' => $result->get_error_message(), 'code' => $result->get_error_code() )); + $this->failJob($jobId, $result->get_error_message()); + return; + } + + do_action('datamachine_log', 'info', 'Safe workspace cleanup completed.', array( 'task' => $this->getTaskType(), 'jobId' => $jobId, 'result' => $result )); + $this->completeJob($jobId, $result); + } + + /** @return array|\WP_Error */ + protected function run_safe_cleanup( array $input ): array|\WP_Error { + return ( new WorkspaceSafeCleanupOrchestrator() )->run($input); + } +} diff --git a/tests/workspace-safe-cleanup-schedule.php b/tests/workspace-safe-cleanup-schedule.php new file mode 100644 index 00000000..8c0e7266 --- /dev/null +++ b/tests/workspace-safe-cleanup-schedule.php @@ -0,0 +1,85 @@ +completed[] = array( $job_id, $result ); + } + + protected function failJob( int $job_id, string $message ): void { + $this->failed[] = array( $job_id, $message ); + } + } +} + +namespace { + define('ABSPATH', dirname(__DIR__) . '/'); + function do_action( string $hook, mixed ...$args ): void {} + + final class WP_Error { + public function __construct( private string $code, private string $message ) {} + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + } + + function workspace_safe_schedule_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new \RuntimeException($message); + } + } + + require_once dirname(__DIR__) . '/inc/Tasks/WorkspaceSafeCleanupTask.php'; + + final class TestWorkspaceSafeCleanupTask extends \DataMachineCode\Tasks\WorkspaceSafeCleanupTask { + public array $inputs = array(); + protected function run_safe_cleanup( array $input ): array|\WP_Error { + $this->inputs[] = $input; + return array( 'success' => true, 'run_id' => 'cleanup-run-safe-task', 'summary' => array( 'removed' => 1 ) ); + } + } + + $meta = \DataMachineCode\Tasks\WorkspaceSafeCleanupTask::getTaskMeta(); + workspace_safe_schedule_assert('workspace_safe_cleanup_enabled' === $meta['setting_key'], 'task exposes its owned setting key'); + workspace_safe_schedule_assert(true === $meta['default_enabled'], 'safe maintenance is enabled by default'); + + \DataMachine\Core\PluginSettings::$settings = array( 'workspace_safe_cleanup_enabled' => false ); + $task = new TestWorkspaceSafeCleanupTask(); + $task->executeTask(10, array()); + workspace_safe_schedule_assert(array() === $task->inputs, 'disabled task does not invoke cleanup'); + workspace_safe_schedule_assert(true === ($task->completed[0][1]['skipped'] ?? false), 'disabled task reports visible skipped state'); + + \DataMachine\Core\PluginSettings::$settings = array( 'workspace_safe_cleanup_enabled' => true ); + $task = new TestWorkspaceSafeCleanupTask(); + $task->executeTask(11, array( 'source' => 'recurring_schedule', 'limit' => 9, 'passes' => 2, 'cycles' => 3, 'until_budget' => '20s', 'force' => true, 'discard_unpushed' => true )); + $input = $task->inputs[0] ?? array(); + workspace_safe_schedule_assert(false === ($input['force'] ?? true), 'scheduled cleanup cannot enable force'); + workspace_safe_schedule_assert(false === ($input['discard_unpushed'] ?? true), 'scheduled cleanup cannot discard unpushed work'); + workspace_safe_schedule_assert(9 === ($input['limit'] ?? 0) && 2 === ($input['passes'] ?? 0) && 3 === ($input['cycles'] ?? 0), 'schedule bounds are forwarded'); + workspace_safe_schedule_assert('20s' === ($input['until_budget'] ?? ''), 'schedule time budget is forwarded'); + workspace_safe_schedule_assert('cleanup-run-safe-task' === ($task->completed[0][1]['run_id'] ?? ''), 'task persists cleanup evidence in its completed result'); + $schedule = \DataMachineCode\Tasks\WorkspaceSafeCleanupTask::recurringSchedule(); + workspace_safe_schedule_assert('hourly' === ($schedule['interval'] ?? ''), 'maintenance schedule is hourly'); + workspace_safe_schedule_assert(true === ($schedule['default_enabled'] ?? false), 'maintenance schedule defaults to enabled'); + workspace_safe_schedule_assert(\DataMachineCode\Tasks\WorkspaceSafeCleanupTask::SETTING_KEY === ($schedule['enabled_setting'] ?? ''), 'maintenance schedule uses the task setting'); + workspace_safe_schedule_assert(array( 'source' => 'recurring_schedule', 'limit' => 25, 'passes' => 5, 'cycles' => 5, 'until_budget' => '45s' ) === ($schedule['task_params'] ?? null), 'maintenance schedule declares bounded invocation parameters'); + $source = file_get_contents(dirname(__DIR__) . '/data-machine-code.php'); + workspace_safe_schedule_assert(false !== strpos($source, '$tasks[\'workspace_safe_cleanup\'] = \\DataMachineCode\\Tasks\\WorkspaceSafeCleanupTask::class'), 'task is registered with DMC'); + workspace_safe_schedule_assert(false !== strpos($source, '$schedules[\'workspace_safe_cleanup\'] = \\DataMachineCode\\Tasks\\WorkspaceSafeCleanupTask::recurringSchedule()'), 'DMC binds the owned recurring schedule'); + + echo "workspace safe cleanup schedule test passed.\n"; +}