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
48 changes: 44 additions & 4 deletions bin/dmc-worktree-provider
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,32 @@ $workspace = (string) ( $argv[2] ?? '' );
$value = (string) ( $argv[3] ?? '' );
$base_sha = (string) ( $argv[4] ?? '' );

if ( ! in_array($operation, array( 'capabilities', 'identity', 'task', 'safety', 'converge', 'plan', 'primary-refresh' ), true) || ( 'capabilities' !== $operation && ( '' === $workspace || '' === $value ) ) || ( 'converge' === $operation && '' === $base_sha ) || ( 'primary-refresh' === $operation && '' !== $base_sha ) ) {
fwrite(STDERR, "Usage: dmc-worktree-provider capabilities | <identity|task|safety|converge|plan|primary-refresh> <workspace-root> <handle|task-url|identity-token|plan-json|repo> [base-sha]\n");
$options = array();
foreach ( array_slice($argv, 'inventory' === $operation ? 3 : 4) as $argument ) {
if ( str_starts_with($argument, '--') ) {
$parts = explode('=', substr($argument, 2), 2);
$options[ $parts[0] ] = $parts[1] ?? true;
}
}

$valid = array( 'capabilities', 'inventory', 'show', 'identity', 'task', 'safety', 'converge', 'plan', 'primary-refresh' );
if ( ! in_array($operation, $valid, true) || ( 'capabilities' !== $operation && '' === $workspace ) || ( ! in_array($operation, array( 'capabilities', 'inventory' ), true) && '' === $value ) || ( 'converge' === $operation && '' === $base_sha ) || ( 'primary-refresh' === $operation && '' !== $base_sha ) ) {
fwrite(STDERR, "Usage: dmc-worktree-provider capabilities | inventory <workspace-root> [--limit=50] [--cursor=<cursor>] [--format=text|json] | show <workspace-root> <handle> [--format=text|json] | <identity|task|safety|converge|plan|primary-refresh> <workspace-root> <value> [base-sha]\n");
exit(2);
}

$provider = new StandaloneWorktreeProvider();
if ( 'plan' === $operation ) {
if ( 'inventory' === $operation ) {
$limit = isset($options['limit']) && is_string($options['limit']) && ctype_digit($options['limit']) ? (int) $options['limit'] : 50;
if ( isset($options['limit']) && ( ! is_string($options['limit']) || ! ctype_digit($options['limit']) ) ) {
fwrite(STDERR, "--limit must be an integer\n");
exit(2);
}
$cursor = isset($options['cursor']) && is_string($options['cursor']) ? $options['cursor'] : null;
$result = $provider->inventory($workspace, $limit, $cursor);
} elseif ( 'show' === $operation ) {
$result = $provider->show($workspace, $value);
} elseif ( 'plan' === $operation ) {
$intent = json_decode($value, true);
if ( ! is_array($intent) ) {
fwrite(STDERR, "plan intent must be a JSON object\n");
Expand All @@ -37,6 +56,27 @@ if ( 'plan' === $operation ) {
};
}

fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n");
$format = (string) ( $options['format'] ?? ( in_array($operation, array( 'inventory', 'show' ), true) ? 'text' : 'json' ) );
if ( ! in_array($format, array( 'text', 'json' ), true) ) {
fwrite(STDERR, "--format must be text or json\n");
exit(2);
}

if ( 'text' === $format && 'inventory' === $operation && 'complete' === ( $result['status'] ?? '' ) ) {
fwrite(STDOUT, 'Standalone workspace inventory: ' . $result['workspace'] . "\n");
foreach ( $result['items'] as $item ) {
$observed = $item['observation'];
fwrite(STDOUT, sprintf("%s\t%s\t%s\t%s\tdirty:%s\tunpushed:%s\n", $item['handle'], $item['kind'], $observed['branch'] ?? '-', isset($observed['head']) && is_string($observed['head']) ? substr($observed['head'], 0, 12) : '-', isset($observed['dirty']) ? ( $observed['dirty'] ? 'yes' : 'no' ) : 'unknown', isset($observed['unpushed']) ? ( $observed['unpushed'] ? 'yes' : 'no' ) : 'unknown'));
}
fwrite(STDOUT, sprintf("Showing %d item(s).%s\n", $result['page']['count'], $result['page']['has_more'] ? ' Continue with --cursor=' . $result['page']['next_cursor'] : ''));
fwrite(STDOUT, "Database lifecycle metadata unavailable. Restore WordPress/database access, then run: wp datamachine-code workspace list --format=json\n");
} elseif ( 'text' === $format && 'show' === $operation && 'complete' === ( $result['status'] ?? '' ) ) {
$item = $result['item'];
$observed = $item['observation'];
fwrite(STDOUT, sprintf("%s (%s)\nPath: %s\nBranch: %s\nHEAD: %s\nDirty: %s\nUnpushed: %s\n", $item['handle'], $item['kind'], $observed['path'], $observed['branch'], $observed['head'] ?? 'unknown', isset($observed['dirty']) ? ( $observed['dirty'] ? 'yes' : 'no' ) : 'unknown', isset($observed['unpushed']) ? ( $observed['unpushed'] ? 'yes' : 'no' ) : 'unknown'));
fwrite(STDOUT, 'Database lifecycle metadata unavailable. Restore WordPress/database access, then run: ' . $result['lifecycle']['recovery']['command'] . "\n");
} else {
fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n");
}
$failed = 'error' === ( $result['status'] ?? '' ) || ( 'primary-refresh' === $operation && 'refused' === ( $result['status'] ?? '' ) );
exit($failed ? 1 : 0);
4 changes: 2 additions & 2 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ public static function worktree_command_definitions(): array {
$worktree_policy = WorktreeContextInjector::worktree_add_policy_schema_properties();
$definitions = array(
'provider' => array(
'shortdesc' => 'Resolve the standalone worktree provider executable.',
'longdesc' => "Returns the executable path from this installed Data Machine Code source tree.\n\n## EXAMPLES\n\n wp datamachine-code workspace worktree provider --format=json",
'shortdesc' => 'Resolve the standalone workspace recovery executable.',
'longdesc' => "Returns the executable path and capabilities from this installed Data Machine Code source tree. When WordPress or its database cannot boot, use that executable for bounded read-only inventory or exact-handle show; lifecycle metadata remains available only through the normal WP-backed commands after recovery.\n\n## EXAMPLES\n\n wp datamachine-code workspace worktree provider --format=json\n <executable> inventory <workspace-root> --limit=50\n <executable> show <workspace-root> <repo@slug> --format=json",
'synopsis' => array( $format ),
),
'add' => array(
Expand Down
3 changes: 3 additions & 0 deletions inc/Runtime/AgentsMdSections.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ private static function register_datamachine_section( string $wp ): void {

**Discovery**
Use `{$wp} datamachine-code workspace --help` and `{$wp} datamachine-code workspace worktree --help` for the live lifecycle contract. Query `workspace list`, `workspace show`, or `workspace hygiene` for current state instead of relying on an embedded inventory snapshot.

**WordPress recovery**
If WordPress or its database cannot boot, use the installed plugin's `bin/dmc-worktree-provider inventory <workspace-root> --limit=50` or `bin/dmc-worktree-provider show <workspace-root> <handle> --format=json`. These standalone operations report bounded live filesystem/Git observations only; restore WordPress/database access and return to `{$wp} datamachine-code workspace list` or `workspace show` for authoritative lifecycle metadata.
MD;
}, array(
'label' => 'Data Machine Code',
Expand Down
218 changes: 217 additions & 1 deletion inc/Workspace/StandaloneWorktreeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,30 @@ final class StandaloneWorktreeProvider {
private const SAFETY_SCHEMA = 'datamachine-code/worktree-safety/v1';
private const CONVERGE_SCHEMA = 'datamachine-code/worktree-convergence/v1';
private const CAPABILITIES_SCHEMA = 'datamachine-code/worktree-provider-capabilities/v1';
private const INVENTORY_SCHEMA = 'datamachine-code/standalone-workspace-inventory/v1';
private const SHOW_SCHEMA = 'datamachine-code/standalone-workspace-show/v1';
private const TOKEN_PREFIX = 'dmc-worktree-v1.';
private const CURSOR_PREFIX = 'dmc-inventory-v1.';
private const PROBE_TIMEOUT = 2.0;
private const LOCK_TIMEOUT = 2.0;
private const TASK_MAX_MATCHES = 200;
private const TASK_MAX_ENTRIES = 10000;
private const INVENTORY_DEFAULT_LIMIT = 50;
private const INVENTORY_MAX_LIMIT = 200;
private const INVENTORY_MAX_ENTRIES = 10000;

/** @return array<string,mixed> */
public function capabilities(): array {
return array(
'schema' => self::CAPABILITIES_SCHEMA,
'operations' => array( 'capabilities', 'identity', 'task', 'safety', 'converge', 'plan', 'primary-refresh' ),
'operations' => array( 'capabilities', 'inventory', 'show', 'identity', 'task', 'safety', 'converge', 'plan', 'primary-refresh' ),
'inventory_schema' => self::INVENTORY_SCHEMA,
'show_schema' => self::SHOW_SCHEMA,
'inventory_default_limit' => self::INVENTORY_DEFAULT_LIMIT,
'inventory_max_limit' => self::INVENTORY_MAX_LIMIT,
'inventory_max_entries' => self::INVENTORY_MAX_ENTRIES,
'inventory_mutating' => false,
'inventory_networked' => false,
'identity_schema' => self::IDENTITY_SCHEMA,
'task_resolution_schema' => self::TASK_SCHEMA,
'plan_schema' => WorktreePlanEnvelope::SCHEMA,
Expand Down Expand Up @@ -81,6 +94,126 @@ public function refresh_primary( string $workspace, string $repo, string $remote
return ( new StandalonePrimaryRefresher() )->refresh($workspace, $repo, $remote);
}

/**
* Observe one bounded page of direct-child repositories without WordPress.
*
* @return array<string,mixed>
*/
public function inventory( string $workspace, int $limit = self::INVENTORY_DEFAULT_LIMIT, ?string $cursor = null ): array {
$started = microtime(true);
$workspace_real = realpath($workspace);
if ( false === $workspace_real || ! is_dir($workspace_real) ) {
return $this->error('workspace_not_found', 'The canonical workspace root does not exist.', $started);
}
if ( $limit < 1 || $limit > self::INVENTORY_MAX_LIMIT ) {
return $this->error('invalid_inventory_limit', sprintf('Inventory limit must be between 1 and %d.', self::INVENTORY_MAX_LIMIT), $started);
}

$after = $this->decode_inventory_cursor($cursor);
if ( null !== $cursor && null === $after ) {
return $this->error('invalid_inventory_cursor', 'The inventory cursor is invalid.', $started);
}

$handles = array();
$scanned = 0;
try {
foreach ( new \FilesystemIterator($workspace_real, \FilesystemIterator::SKIP_DOTS) as $entry ) {
if ( ++$scanned > self::INVENTORY_MAX_ENTRIES ) {
return $this->error('inventory_workspace_entries_overflow', 'Inventory exceeded the bounded workspace entry limit.', $started);
}
if ( ! $entry->isDir() || $entry->isLink() || ! file_exists($entry->getPathname() . '/.git') ) {
continue;
}
$handle = $entry->getBasename();
$parsed = WorkspaceHandle::parse($handle)->to_array();
if ( $handle === $parsed['dir_name'] && '' !== $parsed['repo'] ) {
$handles[] = $handle;
}
}
} catch ( \UnexpectedValueException ) {
return $this->error('workspace_unreadable', 'The canonical workspace root could not be read.', $started);
}

sort($handles, SORT_STRING);
if ( null !== $after ) {
$handles = array_values(array_filter($handles, static fn( string $handle ): bool => 0 < strcmp($handle, $after)));
}
$page = array_slice($handles, 0, $limit + 1);
$has_more = count($page) > $limit;
if ( $has_more ) {
array_pop($page);
}

$items = array();
foreach ( $page as $handle ) {
$items[] = $this->observe_handle($workspace_real, $handle);
}
$next_cursor = $has_more && array() !== $page ? $this->encode_inventory_cursor((string) end($page)) : null;

return array(
'schema' => self::INVENTORY_SCHEMA,
'status' => 'complete',
'workspace' => $workspace_real,
'observed_at' => gmdate('c'),
'items' => $items,
'page' => array(
'limit' => $limit,
'cursor' => $cursor,
'next_cursor' => $next_cursor,
'has_more' => $has_more,
'count' => count($items),
),
'scan' => array(
'scope' => 'direct_children',
'entries_examined' => $scanned,
'entry_limit' => self::INVENTORY_MAX_ENTRIES,
'git_probes' => count($items),
),
'lifecycle' => $this->unavailable_lifecycle('wp datamachine-code workspace list --format=json'),
'execution' => $this->read_only_execution(),
'latency_ms' => $this->elapsed_ms($started),
);
}

/**
* Observe one exact direct-child handle without scanning the workspace.
*
* @return array<string,mixed>
*/
public function show( string $workspace, string $handle ): array {
$started = microtime(true);
$workspace_real = realpath($workspace);
if ( false === $workspace_real || ! is_dir($workspace_real) ) {
return $this->error('workspace_not_found', 'The canonical workspace root does not exist.', $started);
}

$identity = $this->resolve_identity($workspace_real, $handle);
if ( 'owned' !== ( $identity['status'] ?? '' ) ) {
return array(
'schema' => self::SHOW_SCHEMA,
'status' => 'not_found',
'workspace' => $workspace_real,
'handle' => $handle,
'reason' => $identity['reason'] ?? 'worktree_not_found',
'lifecycle' => $this->unavailable_lifecycle(sprintf('wp datamachine-code workspace show %s --format=json', escapeshellarg($handle))),
'execution' => $this->read_only_execution(),
'latency_ms' => $this->elapsed_ms($started),
);
}

return array(
'schema' => self::SHOW_SCHEMA,
'status' => 'complete',
'workspace' => $workspace_real,
'observed_at' => gmdate('c'),
'item' => $this->observe_identity($workspace_real, $identity),
'lookup' => array( 'scope' => 'exact_handle', 'workspace_scanned' => false, 'git_probes' => 1 ),
'lifecycle' => $this->unavailable_lifecycle(sprintf('wp datamachine-code workspace show %s --format=json', escapeshellarg($handle))),
'execution' => $this->read_only_execution(),
'latency_ms' => $this->elapsed_ms($started),
);
}

/**
* Produce a non-mutating, digest-addressed allocation plan without WordPress.
*
Expand Down Expand Up @@ -917,6 +1050,89 @@ private function read_head( string $path ): ?string {
return $result['success'] ? trim($result['stdout']) : null;
}

/** @return array<string,mixed> */
private function observe_handle( string $workspace, string $handle ): array {
$identity = $this->resolve_identity($workspace, $handle);
if ( 'owned' === ( $identity['status'] ?? '' ) ) {
return $this->observe_identity($workspace, $identity);
}

$parsed = WorkspaceHandle::parse($handle)->to_array();
return array(
'handle' => $handle,
'repo' => $parsed['repo'],
'kind' => $parsed['is_worktree'] ? 'worktree' : 'primary',
'observation' => array(
'source' => 'filesystem_git',
'status' => 'unavailable',
'reason' => $identity['reason'] ?? 'identity_unavailable',
),
'lifecycle' => $this->unavailable_lifecycle(sprintf('wp datamachine-code workspace show %s --format=json', escapeshellarg($handle))),
);
}

/** @param array<string,mixed> $identity @return array<string,mixed> */
private function observe_identity( string $workspace, array $identity ): array {
$safety = $this->attest_safety($workspace, (string) $identity['token']);
$head = $this->read_head((string) $identity['path']);
return array(
'handle' => $identity['handle'],
'repo' => WorkspaceHandle::parse((string) $identity['handle'])->repo(),
'kind' => ! empty($identity['primary']) ? 'primary' : 'worktree',
'observation' => array(
'source' => 'filesystem_git',
'status' => 'observed',
'path' => $identity['path'],
'branch' => $identity['branch'],
'head' => $head,
'dirty' => 'attested' === ( $safety['status'] ?? '' ) ? (bool) $safety['dirty'] : null,
'unpushed' => 'attested' === ( $safety['status'] ?? '' ) ? (bool) $safety['unpushed'] : null,
'probe_status' => 'attested' === ( $safety['status'] ?? '' ) && null !== $head ? 'complete' : 'unavailable',
'task_url' => $identity['task_url'],
'task_ref' => $identity['task_ref'],
),
'lifecycle' => $this->unavailable_lifecycle(sprintf('wp datamachine-code workspace show %s --format=json', escapeshellarg((string) $identity['handle']))),
);
}

/** @return array<string,mixed> */
private function unavailable_lifecycle( string $recovery_command ): array {
return array(
'source' => 'wordpress_database',
'status' => 'unavailable',
'unavailable_fields' => array( 'lifecycle_state', 'cleanup_signal', 'origin', 'last_seen_at' ),
'recovery' => array(
'message' => 'Restore WordPress/database availability and use the canonical WP-backed workspace command for lifecycle state.',
'command' => $recovery_command,
),
);
}

/** @return array<string,bool> */
private function read_only_execution(): array {
return array(
'wordpress_loaded' => false,
'database_accessed' => false,
'network_accessed' => false,
'mutated' => false,
);
}

private function encode_inventory_cursor( string $handle ): string {
return self::CURSOR_PREFIX . rtrim(strtr(base64_encode($handle), '+/', '-_'), '=');
}

private function decode_inventory_cursor( ?string $cursor ): ?string {
if ( null === $cursor || ! str_starts_with($cursor, self::CURSOR_PREFIX) ) {
return null;
}
$decoded = base64_decode(strtr(substr($cursor, strlen(self::CURSOR_PREFIX)), '-_', '+/'), true);
if ( false === $decoded || '' === $decoded || str_contains($decoded, '/') || str_contains($decoded, "\0") ) {
return null;
}
return $decoded;
}

private function run_convergence_test_hook( string $path ): void {
$hook = getenv('DMC_WORKTREE_PROVIDER_TEST_CONVERGE_HOOK');
if ( false !== $hook && '' !== $hook ) {
Expand Down
4 changes: 4 additions & 0 deletions tests/smoke-agents-md-sections.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ function assert_not_contains( string $needle, string $haystack, string $message
assert_contains('workspace worktree add <repo> <branch> --from=origin/<base>', $default, 'worktree creation route missing');
assert_contains('workspace worktree finalize <repo@slug> --pr=<url>', $default, 'worktree finalization route missing');
assert_contains('**Discovery**', $default, 'DMC discovery guidance missing');
assert_contains('**WordPress recovery**', $default, 'DMC WordPress recovery guidance missing');
assert_contains('bin/dmc-worktree-provider inventory <workspace-root> --limit=50', $default, 'Standalone bounded inventory recovery route missing');
assert_contains('bin/dmc-worktree-provider show <workspace-root> <handle> --format=json', $default, 'Standalone targeted show recovery route missing');
assert_contains('restore WordPress/database access', $default, 'Standalone guidance did not return operators to DB-backed lifecycle authority');
assert_not_contains('adopt|clone|list|show|path|hygiene', $default, 'enumerated workspace commands returned');
assert_not_contains('wp-content/plugins/', $default, 'DMC duplicated WordPress source guidance');
assert_not_contains('Snapshot summary:', $default, 'DMC embedded workspace inventory state');
Expand Down
Loading
Loading