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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,14 +280,16 @@ on it are how parallel agents corrupt each other's work.

- WordPress 6.9+
- PHP 8.2+
- [Data Machine](https://github.com/Extra-Chill/data-machine) plugin (core)
- [Data Machine](https://github.com/Extra-Chill/data-machine) 0.139.6+ when using model-facing ability tool projections
- A driver for the abilities you plan to use — at least one of:
- An external coding-agent runtime on the same host; see [`wp-coding-agents`](https://github.com/Extra-Chill/wp-coding-agents) for an opinionated setup.
- A Data Machine flow on the site that calls DMC's tools / abilities (in-process driver).
- A CI workflow that boots WordPress with DMC loaded and runs a DM flow against it; see [`wc-site-generator`](https://github.com/chubes4/wc-site-generator) for the canonical Playground-based example.
- Shell-backed workspace/git features require `exec()`, a local `git` binary, and a visible writable workspace path.
DMC's abilities still register without a co-located runtime. API-first flows can exercise GitHub abilities directly; an idle workspace is only relevant when using workspace/git abilities.

Model-facing workspace tools and one-to-one GitHub tools are projected directly from their canonical abilities through Data Machine. The remaining `GitHubTools` wrappers are intentionally bespoke: `manage_github_issue` composes update, close, and comment abilities, while `add_label_to_issue` adapts a single model-facing `label` to the canonical ability's `labels` collection.

## Installation

1. Install and activate Data Machine core
Expand Down
40 changes: 40 additions & 0 deletions bin/dmc-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

$root = dirname(__DIR__);
$requested = array_slice($argv, 1);
$tests = array();

if ( array() === $requested ) {
$tests = glob($root . '/tests/*.php') ?: array();
} else {
foreach ( $requested as $test ) {
$path = str_starts_with($test, '/') ? $test : $root . '/' . ltrim($test, '/');
if ( is_file($path) ) {
$tests[] = $path;
continue;
}
fwrite(STDERR, sprintf("Unknown test: %s\n", $test));
exit(2);
}
}

sort($tests, SORT_STRING);
$failures = array();
foreach ( $tests as $test ) {
$relative = ltrim(str_replace($root, '', $test), '/');
fwrite(STDOUT, sprintf("\n[%s]\n", $relative));
passthru(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($test), $exit_code);
if ( 0 !== $exit_code ) {
$failures[] = $relative;
}
}

if ( array() !== $failures ) {
fwrite(STDERR, sprintf("\n%d/%d tests failed:\n%s\n", count($failures), count($tests), implode("\n", $failures)));
exit(1);
}

fwrite(STDOUT, sprintf("\n%d tests passed.\n", count($tests)));
1 change: 0 additions & 1 deletion data-machine-code.php
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,6 @@ function datamachine_code_load_chat_tools() {
new \DataMachineCode\Tools\GitHubIssueTool();
new \DataMachineCode\Tools\GitHubPullRequestTool();
new \DataMachineCode\Tools\GitHubTools();
new \DataMachineCode\Tools\WorkspaceTools();
new \DataMachineCode\Tools\WorkspaceDiffTools();
new \DataMachineCode\Tools\WordPressRuntimeTools();
}
Expand Down
185 changes: 19 additions & 166 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
use DataMachineCode\Workspace\WorkspaceWriter;
use DataMachineCode\Workspace\WorktreeContextInjector;
use DataMachineCode\Workspace\WorktreeDiskBudget;
use DataMachineCode\Workspace\WorktreeAllocationRequest;
use DataMachineCode\Workspace\WorktreeAllocationOperation;
use DataMachineCode\Support\GitRunner;
use DataMachineCode\Support\RuntimeCapabilities;
use DataMachineCode\Runtime\RuntimeSourceSkewDiagnostic;
Expand Down Expand Up @@ -4603,127 +4605,10 @@ public static function prRebase( array $input ): array|\WP_Error {
* @return array
*/
public static function worktreeAdd( array $input ): array|\WP_Error {
// Default inject_context=true; only false when explicitly provided.
$inject_context = array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true;
// Default bootstrap=true; only false when explicitly provided.
$bootstrap = array_key_exists( 'bootstrap', $input ) ? (bool) $input['bootstrap'] : true;
// Default allow_stale=false (gate enforced); only true when explicitly opted in.
$allow_stale = array_key_exists( 'allow_stale', $input ) ? (bool) $input['allow_stale'] : false;
// Default allow_unverified_freshness=false (fetch-failure gate enforced).
$allow_unverified_freshness = array_key_exists( 'allow_unverified_freshness', $input ) ? (bool) $input['allow_unverified_freshness'] : false;
// Default rebase_base=false; only true when explicitly requested.
$rebase_base = array_key_exists( 'rebase_base', $input ) ? (bool) $input['rebase_base'] : false;
$force = ! empty( $input['force'] );
$remediate_capacity = ! empty( $input['remediate_capacity'] );
$remediate_capacity_dry_run = ! empty( $input['remediate_capacity_dry_run'] );
$allow_percentage_byte_floor_exception = ! empty( $input['allow_percentage_byte_floor_exception'] );
$progress_callback = isset( $input['progress_callback'] ) && is_callable( $input['progress_callback'] ) ? $input['progress_callback'] : null;
$require_task_tracker = array_key_exists( 'require_task_tracker', $input ) ? (bool) $input['require_task_tracker'] : true;
$task = array();
$intent = array();
$reuse_policy = isset( $input['reuse_policy'] ) ? (string) $input['reuse_policy'] : 'reuse_compatible';
if ( isset( $input['task_url'] ) && '' !== trim( (string) $input['task_url'] ) ) {
$task['task_url'] = (string) $input['task_url'];
}
if ( isset( $input['task_ref'] ) && '' !== trim( (string) $input['task_ref'] ) ) {
$task['task_ref'] = (string) $input['task_ref'];
}
foreach ( array( 'purpose', 'owner_run_ref', 'cleanup_policy' ) as $key ) {
if ( array_key_exists( $key, $input ) ) {
$intent[ $key ] = $input[ $key ];
}
$result = ( new WorktreeAllocationOperation() )->add(WorktreeAllocationRequest::from_input($input));
if ( is_array($result) && 'github_api' === ( $result['backend'] ?? null ) ) {
$result = self::decorate_remote_workspace_result('worktree_add', $result);
}

$workspace = new Workspace();
$task = WorktreeContextInjector::resolve_task_metadata( $task ) ?? array();
if ( $require_task_tracker && empty( $task ) && RemoteWorkspaceBackend::should_handle() && ! self::hasLocalPrimaryCheckout( $workspace, (string) ( $input['repo'] ?? '' ) ) ) {
return new \WP_Error( 'worktree_task_tracker_required', 'Refusing to create a managed worktree without a valid task URL or task reference.', array( 'status' => 400 ) );
}
if ( RemoteWorkspaceBackend::should_handle() && self::hasLocalPrimaryCheckout( $workspace, (string) ( $input['repo'] ?? '' ) ) ) {
return self::worktree_add_response(
$workspace->worktree_add(
$input['repo'] ?? '',
$input['branch'] ?? '',
$input['from'] ?? null,
$inject_context,
$bootstrap,
$allow_stale,
$rebase_base,
$force,
$task,
$allow_unverified_freshness,
$require_task_tracker,
$intent,
$reuse_policy,
$remediate_capacity,
$remediate_capacity_dry_run,
$progress_callback,
array(),
$allow_percentage_byte_floor_exception
),
$input
);
}

if ( RemoteWorkspaceBackend::should_handle() ) {
if ( $allow_percentage_byte_floor_exception ) {
return new \WP_Error(
'remote_worktree_percentage_byte_floor_exception_unsupported',
'Percentage-byte-floor admission requires a local workspace with measured capacity semantics.',
array(
'status' => 400,
'remediation' => array(
'code' => 'local_workspace_capacity_required',
'message' => 'Run the request against a local managed workspace, where byte and inode capacity can be measured and revalidated.',
),
)
);
}
if ( $remediate_capacity || $remediate_capacity_dry_run ) {
return new \WP_Error(
'remote_worktree_capacity_remediation_unsupported',
'Capacity remediation requires a local workspace because remote workspace allocation has no filesystem capacity or cleanup lifecycle.',
array(
'status' => 400,
'remediate_capacity' => $remediate_capacity,
'remediate_capacity_dry_run' => $remediate_capacity_dry_run,
)
);
}
$result = ( new RemoteWorkspaceBackend() )->worktree_add(
$input['repo'] ?? '',
$input['branch'] ?? '',
$input['from'] ?? null,
$task,
$intent,
$reuse_policy,
$allow_unverified_freshness
);
if ( ! self::shouldFallbackToLocalWorkspace( $result ) ) {
return self::worktree_add_response( self::decorate_remote_workspace_result( 'worktree_add', $result ), $input );
}
}

$result = $workspace->worktree_add(
$input['repo'] ?? '',
$input['branch'] ?? '',
$input['from'] ?? null,
$inject_context,
$bootstrap,
$allow_stale,
$rebase_base,
$force,
$task,
$allow_unverified_freshness,
$require_task_tracker,
$intent,
$reuse_policy,
$remediate_capacity,
$remediate_capacity_dry_run,
$progress_callback,
array(),
$allow_percentage_byte_floor_exception
);
return self::worktree_add_response( $result, $input );
}

Expand Down Expand Up @@ -4766,8 +4651,7 @@ private static function worktree_add_response( array|\WP_Error $result, array $i

/** Plan a local worktree using the same typed fields and defaults as add. */
public static function worktreePlan( array $input ): array|\WP_Error {
$request = self::worktreeIntentRequest( $input );
return ( new Workspace() )->worktree_plan( ...$request );
return ( new WorktreeAllocationOperation() )->plan(WorktreeAllocationRequest::from_input($input));
}

/** Apply a previously returned local worktree plan. */
Expand All @@ -4785,7 +4669,7 @@ public static function worktreeHandoffRevalidate( array $input ): array|\WP_Erro
}

public static function worktreeAttachTracker( array $input ): array|\WP_Error {
return ( new Workspace() )->worktree_attach_tracker((string) ( $input['handle'] ?? '' ), array_filter(array(
return ( new Workspace() )->worktree_attach_tracker( (string) ( $input['handle'] ?? '' ), array_filter(array(
'task_url' => $input['task_url'] ?? null,
'task_ref' => $input['task_ref'] ?? null,
), static fn( mixed $value ): bool => is_string($value) && '' !== trim($value)), ! empty($input['dry_run']));
Expand All @@ -4804,41 +4688,23 @@ public static function worktreeLegacyHandoffApply( array $input ): array|\WP_Err
return ( new Workspace() )->worktree_apply_legacy_handoff( (array) ( $input['plan'] ?? array() ), (string) ( $input['mode'] ?? '' ) );
}

/** @return array<int,mixed> */
private static function worktreeIntentRequest( array $input ): array {
$task = array_filter(
array(
'task_url' => $input['task_url'] ?? null,
'task_ref' => $input['task_ref'] ?? null,
),
static fn( $value ): bool => is_string( $value ) && '' !== trim( $value )
);
$intent = array();
foreach ( array( 'purpose', 'owner_run_ref', 'cleanup_policy' ) as $key ) {
if ( array_key_exists( $key, $input ) ) {
$intent[ $key ] = $input[ $key ];
}
}
return array( (string) ( $input['repo'] ?? '' ), (string) ( $input['branch'] ?? '' ), $input['from'] ?? null, array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true, array_key_exists( 'bootstrap', $input ) ? (bool) $input['bootstrap'] : true, ! empty( $input['allow_stale'] ), ! empty( $input['rebase_base'] ), ! empty( $input['force'] ), $task, ! empty( $input['allow_unverified_freshness'] ), array_key_exists( 'require_task_tracker', $input ) ? (bool) $input['require_task_tracker'] : true, $intent, (string) ( $input['reuse_policy'] ?? 'reuse_compatible' ), ! empty( $input['allow_percentage_byte_floor_exception'] ) );
}

/** @return array<string,array<string,mixed>> */
private static function worktreeIntentSchemaProperties(): array {
$policy = WorktreeContextInjector::worktree_add_policy_schema_properties();
return array(
'repo' => array( 'type' => 'string' ),
'branch' => array( 'type' => 'string' ),
'from' => array( 'type' => 'string' ),
'inject_context' => array( 'type' => 'boolean' ),
'bootstrap' => array( 'type' => 'boolean' ),
'allow_stale' => array( 'type' => 'boolean' ),
'allow_unverified_freshness' => array( 'type' => 'boolean' ),
'rebase_base' => array( 'type' => 'boolean' ),
'force' => array( 'type' => 'boolean' ),
'repo' => array( 'type' => 'string' ),
'branch' => array( 'type' => 'string' ),
'from' => array( 'type' => 'string' ),
'inject_context' => array( 'type' => 'boolean' ),
'bootstrap' => array( 'type' => 'boolean' ),
'allow_stale' => array( 'type' => 'boolean' ),
'allow_unverified_freshness' => array( 'type' => 'boolean' ),
'rebase_base' => array( 'type' => 'boolean' ),
'force' => array( 'type' => 'boolean' ),
'allow_percentage_byte_floor_exception' => array( 'type' => 'boolean' ),
'task_url' => array( 'type' => 'string' ),
'task_ref' => array( 'type' => 'string' ),
'require_task_tracker' => array( 'type' => 'boolean' ),
'task_url' => array( 'type' => 'string' ),
'task_ref' => array( 'type' => 'string' ),
'require_task_tracker' => array( 'type' => 'boolean' ),
...$policy,
);
}
Expand Down Expand Up @@ -5019,19 +4885,6 @@ private static function worktreeHandoffAllocationIdentitySchemaRequired(): array
return array( 'version', 'allocation_id', 'handle', 'path', 'branch', 'worktree_sha', 'resolved_base_ref', 'metadata_digest', 'digest' );
}

/**
* Whether a repo argument resolves to an editable local primary checkout.
*/
private static function hasLocalPrimaryCheckout( Workspace $workspace, string $repo ): bool {
$result = self::showLocalWorkspaceHandleIfPresent( $workspace, $repo );
if ( null === $result ) {
return false;
}

$path = (string) ( $result['path'] ?? '' );
return ! str_contains( basename( $path ), '@' );
}

/**
* Return local workspace details for an existing local handle, if present.
*/
Expand Down
Loading
Loading