From 6b5a8a394ba83276f3ca779464e4960ad7b9b880 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 30 Aug 2026 21:45:33 -0400 Subject: [PATCH] feat: inspect workspaces without WordPress --- bin/dmc-worktree-provider | 48 +++- inc/Cli/Commands/WorkspaceCommand.php | 4 +- inc/Runtime/AgentsMdSections.php | 3 + inc/Workspace/StandaloneWorktreeProvider.php | 218 +++++++++++++++++- tests/smoke-agents-md-sections.php | 4 + tests/standalone-workspace-inventory.php | 96 ++++++++ .../standalone-worktree-provider-command.php | 4 + tests/standalone-worktree-provider.php | 2 + tests/worktree-command-help-snapshots.php | 4 +- 9 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 tests/standalone-workspace-inventory.php diff --git a/bin/dmc-worktree-provider b/bin/dmc-worktree-provider index cd69ab01..181cc09e 100755 --- a/bin/dmc-worktree-provider +++ b/bin/dmc-worktree-provider @@ -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 | [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 [--limit=50] [--cursor=] [--format=text|json] | show [--format=text|json] | [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"); @@ -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); diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 3708de30..bc0f5a3f 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -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 inventory --limit=50\n show --format=json", 'synopsis' => array( $format ), ), 'add' => array( diff --git a/inc/Runtime/AgentsMdSections.php b/inc/Runtime/AgentsMdSections.php index 66ed7b21..ef74ff6b 100644 --- a/inc/Runtime/AgentsMdSections.php +++ b/inc/Runtime/AgentsMdSections.php @@ -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 --limit=50` or `bin/dmc-worktree-provider show --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', diff --git a/inc/Workspace/StandaloneWorktreeProvider.php b/inc/Workspace/StandaloneWorktreeProvider.php index 3f9f2f7c..cfaf9b38 100644 --- a/inc/Workspace/StandaloneWorktreeProvider.php +++ b/inc/Workspace/StandaloneWorktreeProvider.php @@ -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 */ 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, @@ -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 + */ + 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 + */ + 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. * @@ -917,6 +1050,89 @@ private function read_head( string $path ): ?string { return $result['success'] ? trim($result['stdout']) : null; } + /** @return array */ + 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 $identity @return array */ + 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 */ + 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 */ + 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 ) { diff --git a/tests/smoke-agents-md-sections.php b/tests/smoke-agents-md-sections.php index 79ed7628..e61d9857 100644 --- a/tests/smoke-agents-md-sections.php +++ b/tests/smoke-agents-md-sections.php @@ -114,6 +114,10 @@ function assert_not_contains( string $needle, string $haystack, string $message assert_contains('workspace worktree add --from=origin/', $default, 'worktree creation route missing'); assert_contains('workspace worktree finalize --pr=', $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 --limit=50', $default, 'Standalone bounded inventory recovery route missing'); + assert_contains('bin/dmc-worktree-provider show --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'); diff --git a/tests/standalone-workspace-inventory.php b/tests/standalone-workspace-inventory.php new file mode 100644 index 00000000..6b2e3093 --- /dev/null +++ b/tests/standalone-workspace-inventory.php @@ -0,0 +1,96 @@ + array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes); + standalone_inventory_assert(is_resource($process), 'Could not start fixture command.'); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return array( 'status' => proc_close($process), 'stdout' => $stdout, 'stderr' => $stderr ); +} + +function standalone_inventory_git( string $path, array $arguments ): void { + $result = standalone_inventory_run(array_merge(array( 'git', '-C', $path ), $arguments)); + standalone_inventory_assert(0 === $result['status'], 'Git fixture command failed: ' . $result['stderr']); +} + +function standalone_inventory_remove( string $path ): void { + if ( ! is_dir($path) ) { + return; + } + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ( $iterator as $item ) { + $item->isDir() && ! $item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($path); +} + +$root = sys_get_temp_dir() . '/dmc-standalone-inventory-' . bin2hex(random_bytes(6)); +$alpha = $root . '/alpha'; +$worktree = $root . '/alpha@fix-one'; +$beta = $root . '/beta'; +$unrelated = $root . '/unrelated'; +$script = dirname(__DIR__) . '/bin/dmc-worktree-provider'; + +mkdir($root, 0777, true); +try { + foreach ( array( $alpha, $beta ) as $primary ) { + mkdir($primary); + standalone_inventory_git($primary, array( 'init', '-b', 'main' )); + standalone_inventory_git($primary, array( 'config', 'user.name', 'Fixture' )); + standalone_inventory_git($primary, array( 'config', 'user.email', 'fixture@example.test' )); + file_put_contents($primary . '/README.md', basename($primary) . "\n"); + standalone_inventory_git($primary, array( 'add', 'README.md' )); + standalone_inventory_git($primary, array( 'commit', '-m', 'fixture' )); + } + standalone_inventory_git($alpha, array( 'worktree', 'add', '-b', 'fix/one', $worktree )); + mkdir($unrelated); + file_put_contents($unrelated . '/sentinel', "must remain untouched\n"); + $head_before = trim(standalone_inventory_run(array( 'git', '-C', $worktree, 'rev-parse', 'HEAD' ))['stdout']); + + $page_one = standalone_inventory_run(array( PHP_BINARY, $script, 'inventory', $root, '--limit=2', '--format=json' )); + standalone_inventory_assert(0 === $page_one['status'], 'Standalone inventory failed without WordPress: ' . $page_one['stderr']); + $first = json_decode($page_one['stdout'], true, 512, JSON_THROW_ON_ERROR); + standalone_inventory_assert('datamachine-code/standalone-workspace-inventory/v1' === ($first['schema'] ?? null), 'Inventory schema changed.'); + standalone_inventory_assert(array( 'alpha', 'alpha@fix-one' ) === array_column($first['items'] ?? array(), 'handle'), 'Inventory page was not deterministic.'); + standalone_inventory_assert(true === ($first['page']['has_more'] ?? null) && is_string($first['page']['next_cursor'] ?? null), 'Inventory omitted its continuation cursor.'); + standalone_inventory_assert(2 === ($first['scan']['git_probes'] ?? null), 'Inventory probed outside the requested page.'); + standalone_inventory_assert('unavailable' === ($first['lifecycle']['status'] ?? null) && 'wordpress_database' === ($first['lifecycle']['source'] ?? null), 'Inventory confused observations with DB lifecycle state.'); + standalone_inventory_assert(array( 'wordpress_loaded' => false, 'database_accessed' => false, 'network_accessed' => false, 'mutated' => false ) === ($first['execution'] ?? null), 'Inventory execution contract is not standalone and read-only.'); + + $page_two = standalone_inventory_run(array( PHP_BINARY, $script, 'inventory', $root, '--limit=2', '--cursor=' . $first['page']['next_cursor'], '--format=json' )); + $second = json_decode($page_two['stdout'], true, 512, JSON_THROW_ON_ERROR); + standalone_inventory_assert(array( 'beta' ) === array_column($second['items'] ?? array(), 'handle') && false === ($second['page']['has_more'] ?? null), 'Inventory pagination duplicated or skipped a handle.'); + + $show = standalone_inventory_run(array( PHP_BINARY, $script, 'show', $root, 'alpha@fix-one', '--format=json' )); + standalone_inventory_assert(0 === $show['status'], 'Standalone targeted show failed: ' . $show['stderr']); + $shown = json_decode($show['stdout'], true, 512, JSON_THROW_ON_ERROR); + standalone_inventory_assert('datamachine-code/standalone-workspace-show/v1' === ($shown['schema'] ?? null), 'Show schema changed.'); + standalone_inventory_assert('alpha@fix-one' === ($shown['item']['handle'] ?? null) && 'worktree' === ($shown['item']['kind'] ?? null), 'Show returned the wrong identity.'); + standalone_inventory_assert('fix/one' === ($shown['item']['observation']['branch'] ?? null) && $head_before === ($shown['item']['observation']['head'] ?? null), 'Show omitted local Git facts.'); + standalone_inventory_assert(array( 'scope' => 'exact_handle', 'workspace_scanned' => false, 'git_probes' => 1 ) === ($shown['lookup'] ?? null), 'Show did not guarantee exact-target lookup.'); + standalone_inventory_assert(str_contains((string) ($shown['lifecycle']['recovery']['command'] ?? ''), 'workspace show'), 'Show omitted WP-backed lifecycle recovery.'); + standalone_inventory_assert(file_exists($unrelated . '/sentinel'), 'Read-only standalone inspection mutated an unrelated path.'); + $head_after = trim(standalone_inventory_run(array( 'git', '-C', $worktree, 'rev-parse', 'HEAD' ))['stdout']); + standalone_inventory_assert($head_before === $head_after, 'Standalone inspection mutated Git state.'); + + $text = standalone_inventory_run(array( PHP_BINARY, $script, 'inventory', $root, '--limit=1' )); + standalone_inventory_assert(str_contains($text['stdout'], 'Showing 1 item(s). Continue with --cursor=') && str_contains($text['stdout'], 'Database lifecycle metadata unavailable.'), 'Concise inventory text omitted bounds or recovery guidance.'); + + $invalid = standalone_inventory_run(array( PHP_BINARY, $script, 'inventory', $root, '--limit=201', '--format=json' )); + standalone_inventory_assert(1 === $invalid['status'] && 'invalid_inventory_limit' === (json_decode($invalid['stdout'], true, 512, JSON_THROW_ON_ERROR)['code'] ?? null), 'Inventory accepted output beyond its hard limit.'); +} finally { + standalone_inventory_remove($root); +} + +fwrite(STDOUT, "standalone-workspace-inventory: ok\n"); diff --git a/tests/standalone-worktree-provider-command.php b/tests/standalone-worktree-provider-command.php index 720e91ef..370aaf2d 100644 --- a/tests/standalone-worktree-provider-command.php +++ b/tests/standalone-worktree-provider-command.php @@ -41,6 +41,10 @@ function standalone_provider_command_assert( bool $condition, string $message ): standalone_provider_command_assert($executable === ( $payload['executable'] ?? null ), 'Provider command did not emit its resolved executable.'); standalone_provider_command_assert(in_array('plan', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone planning.'); standalone_provider_command_assert(in_array('primary-refresh', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone primary refresh.'); + standalone_provider_command_assert(in_array('inventory', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone inventory.'); + standalone_provider_command_assert(in_array('show', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone show.'); + standalone_provider_command_assert(200 === ($payload['capabilities']['inventory_max_limit'] ?? null), 'Provider command omitted the inventory output bound.'); + standalone_provider_command_assert(false === ($payload['capabilities']['inventory_mutating'] ?? null) && false === ($payload['capabilities']['inventory_networked'] ?? null), 'Provider command did not identify inventory as local read-only inspection.'); standalone_provider_command_assert(\DataMachineCode\Workspace\StandalonePrimaryRefresher::SCHEMA === ($payload['capabilities']['primary_refresh_schema'] ?? null), 'Provider command capabilities omitted the primary refresh schema.'); standalone_provider_command_assert(true === ($payload['capabilities']['primary_refresh_mutating'] ?? null), 'Provider command did not identify primary refresh as mutating.'); standalone_provider_command_assert('origin' === ($payload['capabilities']['primary_refresh_remote'] ?? null), 'Provider command did not bind refresh to the canonical freshness remote.'); diff --git a/tests/standalone-worktree-provider.php b/tests/standalone-worktree-provider.php index 48c50d97..736377ff 100644 --- a/tests/standalone-worktree-provider.php +++ b/tests/standalone-worktree-provider.php @@ -87,6 +87,8 @@ function standalone_provider_remove( string $path ): void { standalone_provider_assert(array( 'task_url', 'task_ref' ) === $capabilities_payload['tracker_fields'], 'Provider capabilities did not advertise both generic tracker fields.'); standalone_provider_assert(in_array('task', $capabilities_payload['operations'], true), 'Provider capabilities did not advertise standalone task resolution.'); standalone_provider_assert(in_array('plan', $capabilities_payload['operations'], true), 'Provider capabilities did not advertise standalone planning.'); + standalone_provider_assert(in_array('inventory', $capabilities_payload['operations'], true) && in_array('show', $capabilities_payload['operations'], true), 'Provider capabilities did not advertise standalone workspace inspection.'); + standalone_provider_assert(50 === ($capabilities_payload['inventory_default_limit'] ?? null) && 200 === ($capabilities_payload['inventory_max_limit'] ?? null), 'Provider capabilities omitted standalone inventory bounds.'); standalone_provider_assert('datamachine-code/worktree-plan/v1' === ($capabilities_payload['plan_schema'] ?? null), 'Provider capabilities did not advertise the plan schema.'); standalone_provider_assert(false === ($capabilities_payload['plan_mutating'] ?? null), 'Provider capabilities advertised planning as mutating.'); standalone_provider_assert('datamachine-code/worktree-task-resolution/v1' === $capabilities_payload['task_resolution_schema'], 'Provider capabilities did not advertise the task resolution schema.'); diff --git a/tests/worktree-command-help-snapshots.php b/tests/worktree-command-help-snapshots.php index 36c7b791..2231784e 100644 --- a/tests/worktree-command-help-snapshots.php +++ b/tests/worktree-command-help-snapshots.php @@ -47,7 +47,9 @@ function worktree_help_assert( bool $condition, string $message ): void { $assert_synopsis('list', array( 'repo', 'state', 'task-ref', 'owner-run-ref', 'limit', 'cursor', 'all', 'envelope', 'with-status', 'with-size', 'full', 'stale', 'include-unmanaged', 'verbose', 'format' )); $assert_synopsis('prune', array( 'dry-run', 'until-budget', 'format' )); $provider = $definitions['provider']; - worktree_help_assert('Resolve the standalone worktree provider executable.' === $provider['shortdesc'], 'Provider help snapshot changed.'); + worktree_help_assert('Resolve the standalone workspace recovery executable.' === $provider['shortdesc'], 'Provider help snapshot changed.'); + worktree_help_assert(str_contains($provider['longdesc'], ' inventory --limit=50'), 'Provider help omitted standalone bounded inventory recovery.'); + worktree_help_assert(str_contains($provider['longdesc'], ' show --format=json'), 'Provider help omitted standalone targeted show recovery.'); worktree_help_assert(array_column($provider['synopsis'], 'name') === array( 'format' ), 'Provider help option snapshot changed.'); $assert_synopsis('emergency-cleanup', array( 'apply', 'force', 'apply-plan', 'format' )); $assert_synopsis('cleanup-artifacts', array( 'dry-run', 'force', 'allow-active-artifact-cleanup', 'allow-unavailable-process-probe', 'limit', 'offset', 'only-handle', 'exhaustive', 'safety-probes', 'sort', 'older-than', 'apply-plan', 'format' ));