diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index c4bb0e2..a5aa20d 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -13,10 +13,9 @@ use Claw\Project\IssueStatus; use Claw\Project\ProjectStore; use Claw\Project\Strategy; -use Claw\Run\ConsoleRunFrontend; -use Claw\Run\IssueRunner; use Claw\Run\Triage; use Claw\Server; +use Claw\ServerClient; use Claw\ServerLocator; use Claw\Trace\Level; use Claw\Trace\TraceReader; @@ -253,23 +252,33 @@ private function runIssue(array $args, ?string $projectDir, ?Level $verbosity): try { $store = $this->resolve($projectDir); $issue = $store->loadIssue($issueId); - $config = Config::load($this->root . '/.env'); } catch (ClawException $e) { fwrite(STDERR, 'claw run: ' . $e->getMessage() . "\n"); return 1; } - $agent = AgentFactory::make($config, new CurlHttpClient()); + // The CLI is a thin client: the server is the one process that writes a project db, so hand the + // run to it rather than opening the db here. If none is running, start one and wait for it. + $client = new ServerClient(new CurlHttpClient(), $this->appHome(), $this->root); + + try { + if ($client->running() === null) { + fwrite(STDOUT, "claw run: no server running for this workspace — starting one…\n"); + } - if ($agent === null) { - fwrite(STDERR, "claw run: agent '{$config->agent}' is not wired yet.\n"); + $server = $client->ensure(); + $client->startRun($server, $store->project()->id, $issue->id); + } catch (ClawException $e) { + fwrite(STDERR, 'claw run: ' . $e->getMessage() . "\n"); return 1; } - return new IssueRunner($this->projectsDir(), $store, $config, $agent, new ConsoleRunFrontend($verbosity)) - ->run($issue); + fwrite(STDOUT, "Run started for issue #{$issue->id} on the server at {$server['host']}:{$server['port']}.\n"); + fwrite(STDOUT, " watch it: claw log\n"); + + return 0; } /** diff --git a/src/ServerClient.php b/src/ServerClient.php new file mode 100644 index 0000000..4b93f96 --- /dev/null +++ b/src/ServerClient.php @@ -0,0 +1,196 @@ +locator = new ServerLocator($workspace); + } + + /** + * The address of a server already answering for this workspace, or null when none is — so the CLI + * can say it is about to start one before {@see ensure()} does. + * + * @return array{host: string, port: int}|null + */ + public function running(): ?array + { + $found = $this->locator->locate(); + + return $found !== null && $this->locator->alive($found['host'], $found['port']) + ? ['host' => $found['host'], 'port' => $found['port']] + : null; + } + + /** + * The address of a live server for this workspace, starting one if none answers. + * + * @return array{host: string, port: int} + * + * @throws ClawException when no server can be reached and none can be started + */ + public function ensure(): array + { + return $this->running() ?? $this->spawn(); + } + + /** + * Ask the server to start the issue's solver. Returns once the run is launched — the server owns + * it from there. + * + * @param array{host: string, port: int} $server + * + * @throws ClawException when the server refuses the start + */ + public function startRun(array $server, string $key, string $issueId): void + { + $url = sprintf( + 'http://%s:%d/api/projects/%s/issues/%s/start', + $server['host'], + $server['port'], + rawurlencode($key), + rawurlencode($issueId), + ); + + $response = $this->http->post($url, '', ['Content-Type: application/json']); + + if ($response->status === 202) { + return; + } + + if ($response->status === 409) { + throw new ClawException('a run for this issue is already active'); + } + + throw new ClawException("the server refused to start the run (HTTP {$response->status})"); + } + + /** + * The argv that launches a server, or null when one cannot be built (bin/claw is not where it + * should be). Returned as a list, not a shell string, so it carries no quoting to get wrong and can + * be handed to proc_open's array form on either platform. Pure, so the shape can be asserted without + * spawning anything. + * + * The extension defaults to the `.so` name php resolves from its extension_dir; on Windows (a `.dll`) + * or a non-standard install, set CLAW_SERVER_EXTENSION to the file to load. + * + * @return list|null + */ + public static function spawnCommand(string $root, string $host, int $port): ?array + { + $claw = $root . \DIRECTORY_SEPARATOR . 'bin' . \DIRECTORY_SEPARATOR . 'claw'; + + if (!is_file($claw)) { + return null; + } + + $extension = getenv('CLAW_SERVER_EXTENSION'); + $extension = $extension === false || $extension === '' ? 'true_async_server.so' : $extension; + + return [ + \PHP_BINARY, '-d', 'extension=' . $extension, + $claw, 'serve', '--host', $host, '--port', (string) $port, + ]; + } + + /** + * Start a server and wait for it to answer. + * + * @return array{host: string, port: int} + * + * @throws ClawException + */ + private function spawn(): array + { + $command = self::spawnCommand($this->root, $this->host, $this->port); + + if ($command === null) { + throw new ClawException('cannot start a server: bin/claw is not where it should be'); + } + + $this->launchDetached($command); + + $waited = 0; + + while ($waited < self::SPAWN_TIMEOUT_MS) { + $found = $this->locator->locate(); + + if ($found !== null && $this->locator->alive($found['host'], $found['port'])) { + return ['host' => $found['host'], 'port' => $found['port']]; + } + + usleep(self::POLL_MS * 1000); + $waited += self::POLL_MS; + } + + throw new ClawException( + 'the server did not come up within ' . (self::SPAWN_TIMEOUT_MS / 1000) . 's — is the server ' + . 'extension available? (set CLAW_SERVER_EXTENSION to its .so/.dll path)', + ); + } + + /** + * Start the server as a process that outlives this CLI and holds none of its stdio. + * + * The two platforms detach differently, and neither is the shell `&` a terminal-launched CLI would + * leak its console through. POSIX: `setsid --fork` gives the server its own session and returns at + * once (without --fork setsid execs in place and proc_close would block for the server's life). + * Windows: `create_new_console` cuts it from this console so it survives the CLI exiting. Either way + * the descriptor spec points stdio at files, so the server never inherits the pipe we were called + * through. + * + * @param list $command + */ + private function launchDetached(array $command): void + { + $log = $this->workspace . \DIRECTORY_SEPARATOR . 'server.log'; + $windows = \PHP_OS_FAMILY === 'Windows'; + + $descriptors = [ + 0 => ['file', $windows ? 'NUL' : '/dev/null', 'r'], + 1 => ['file', $log, 'a'], + 2 => ['file', $log, 'a'], + ]; + + if ($windows) { + $process = @proc_open($command, $descriptors, $pipes, null, null, [ + 'bypass_shell' => true, + 'create_new_console' => true, + 'create_process_group' => true, + ]); + } else { + $process = @proc_open(['setsid', '--fork', ...$command], $descriptors, $pipes); + } + + if (\is_resource($process)) { + proc_close($process); + } + } +} diff --git a/tests/ServerClientTest.php b/tests/ServerClientTest.php new file mode 100644 index 0000000..6e638ab --- /dev/null +++ b/tests/ServerClientTest.php @@ -0,0 +1,116 @@ +workspace = sys_get_temp_dir() . '/claw-client-' . bin2hex(random_bytes(6)); + mkdir($this->workspace); + } + + public function __destruct() + { + @rmdir($this->workspace); + } + + private function client(HttpResponse $response): ServerClient + { + return new ServerClient(new FakeHttpClient($response), $this->workspace, '/opt/claw'); + } + + #[Test] + public function aStartAcceptedReturnsQuietly(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + // a 202 must return without throwing; that the POST was issued is the proof it ran + new ServerClient($http, $this->workspace, '/opt/claw') + ->startRun(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7'); + + Assert::same(str_ends_with((string) $http->lastUrl, '/issues/7/start'), true); + } + + #[Test] + public function anAlreadyActiveRunIsReportedNotSwallowed(): void + { + $threw = false; + + try { + $this->client(new HttpResponse(409, '{"error":"a run for this issue is already active"}')) + ->startRun(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7'); + } catch (ClawException $e) { + $threw = true; + Assert::same(str_contains($e->getMessage(), 'already active'), true); + } + + Assert::same($threw, true); + } + + #[Test] + public function aServerErrorIsSurfaced(): void + { + $threw = false; + + try { + $this->client(new HttpResponse(500, '')) + ->startRun(['host' => '127.0.0.1', 'port' => 8787], 'proj', '7'); + } catch (ClawException $e) { + $threw = true; + Assert::same(str_contains($e->getMessage(), '500'), true); + } + + Assert::same($threw, true); + } + + #[Test] + public function theStartTargetsTheIssuesRunEndpointWithEscapedKeys(): void + { + $http = new FakeHttpClient(new HttpResponse(202, '')); + new ServerClient($http, $this->workspace, '/opt/claw') + ->startRun(['host' => '10.0.0.5', 'port' => 9000], 'a b/c', '7'); + + Assert::same($http->lastUrl, 'http://10.0.0.5:9000/api/projects/a%20b%2Fc/issues/7/start'); + } + + #[Test] + public function runningIsNullWhenNothingIsRecorded(): void + { + Assert::same($this->client(new HttpResponse(202, ''))->running(), null); + } + + #[Test] + public function theSpawnCommandCarriesTheExtensionHostAndPort(): void + { + // bin/claw must exist under the root for a command to be built, so point the root at the repo + $command = ServerClient::spawnCommand(\dirname(__DIR__), '127.0.0.1', 8787) ?? []; + + Assert::same(\in_array('serve', $command, true), true); + Assert::same(\in_array('--host', $command, true), true); + Assert::same(\in_array('127.0.0.1', $command, true), true); + Assert::same(\in_array('8787', $command, true), true); + Assert::same(array_any($command, static fn (string $a): bool => str_starts_with($a, 'extension=')), true); + } + + #[Test] + public function noSpawnCommandWhenBinClawIsAbsent(): void + { + Assert::same(ServerClient::spawnCommand('/nowhere/at/all', '127.0.0.1', 8787), null); + } +}