From 6b5492400854d624d5425d167c5f872b11e0fe29 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:40:47 +0000 Subject: [PATCH] feat(cli): claw -i files the ticket through the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claw -i stops writing the db and running triage in its own process: it resolves the project key (read-only), ensures a server (autospawning one if needed), POSTs the title, and prints OK. The server opens the ticket and triages it behind the response, so -i returns at once and shows only the confirmation. claw -c stays local — it is the bootstrap that creates the db a server later opens, and needs no server. Verified live: fire-and-forget, issue created, returns in the same second. --- src/Cli/WorkflowMode.php | 60 ++++++++++++-------------------------- src/ServerClient.php | 38 ++++++++++++++++++++++++ tests/ServerClientTest.php | 28 ++++++++++++++++++ 3 files changed, 84 insertions(+), 42 deletions(-) diff --git a/src/Cli/WorkflowMode.php b/src/Cli/WorkflowMode.php index 25ee923..49aa0aa 100644 --- a/src/Cli/WorkflowMode.php +++ b/src/Cli/WorkflowMode.php @@ -4,17 +4,12 @@ namespace Claw\Cli; -use Claw\Agent\AgentFactory; use Claw\Config; use Claw\Exceptions\ClawException; use Claw\Http\CurlHttpClient; use Claw\Knowledge\KnowledgeBase; -use Claw\Project\Issue; -use Claw\Project\IssueStatus; use Claw\Project\ProjectStore; use Claw\Project\RunStatus; -use Claw\Project\Strategy; -use Claw\Run\Triage; use Claw\Server; use Claw\ServerClient; use Claw\ServerLocator; @@ -181,54 +176,35 @@ private function createIssue(array $args, ?string $projectDir): int } try { - $store = $this->resolve($projectDir); - $issue = $store->addIssue($title); + // Read-only: the CLI opens the db only to name the project the server should file under. The + // write — and the triage behind it — belong to the server, the one process that writes. + $key = $this->resolve($projectDir)->project()->id; } catch (ClawException $e) { fwrite(STDERR, 'claw -i: ' . $e->getMessage() . "\n"); return 1; } - fwrite(STDOUT, "Issue #{$issue->id} opened: {$issue->title}\n"); - fwrite(STDOUT, ' project: ' . $issue->project . "\n"); - fwrite(STDOUT, ' status: ' . $issue->status->name . "\n"); - - // The ticket is already open and recorded — everything below is the second stage, and the - // issue stands whether or not it succeeds. Run inline rather than detached: on a one-shot - // command there is nothing left to watch the verdict arrive, so it is worth the wait here. - fwrite(STDOUT, " analysing…\n"); - $strategy = $this->triage($store, $issue); - - // Three outcomes, and "no strategy" is not one thing: the ProjectManager may have PARKED the - // ticket for a person, which is a decision, not the absence of one. - $triaged = $store->loadIssue($issue->id); - $parked = $triaged->status === IssueStatus::WaitingHuman; - $type = $triaged->type === null ? 'untyped' : $triaged->type->value; + $client = new ServerClient(new CurlHttpClient(), $this->appHome(), $this->root); - fwrite(STDOUT, match (true) { - $strategy !== null => " type: {$type}\n strategy: {$strategy->value}\n", - $parked => " strategy: none — parked for a person (the ticket cannot be worked on as written)\n", - default => " strategy: not decided (analysis recorded nothing — run `claw run` to solve it anyway)\n", - }); + try { + if ($client->running() === null) { + fwrite(STDOUT, "claw -i: no server running for this workspace — starting one…\n"); + } - return 0; - } + $server = $client->ensure(); + $id = $client->openIssue($server, $key, $title); + } catch (ClawException $e) { + fwrite(STDERR, 'claw -i: ' . $e->getMessage() . "\n"); - /** - * The ProjectManager's analysis of a freshly opened ticket. Returns the strategy it recorded, or - * null when there is no usable agent configured or the analysis produced nothing — neither is a - * reason to fail the command, because the ticket itself is already safely open. - */ - private function triage(ProjectStore $store, Issue $issue): ?Strategy - { - try { - $config = Config::load($this->root . '/.env'); - $agent = AgentFactory::make($config, new CurlHttpClient()); - } catch (ClawException) { - return null; + return 1; } - return $agent === null ? null : new Triage($store, $config, $agent)->analyse($issue); + // Fire-and-forget: the ticket exists and the server triages it behind the response. Nothing to + // wait for, so nothing is shown but the confirmation. + fwrite(STDOUT, "OK — issue #{$id} opened.\n"); + + return 0; } /** diff --git a/src/ServerClient.php b/src/ServerClient.php index e8e82c4..677f059 100644 --- a/src/ServerClient.php +++ b/src/ServerClient.php @@ -94,6 +94,44 @@ public function startRun(array $server, string $key, string $issueId): void throw new ClawException("the server refused to start the run (HTTP {$response->status})"); } + /** + * Open an issue on the server and return its id. The server records it and triages it behind the + * response, so this returns as soon as the ticket exists — it never waits on a model. + * + * @param array{host: string, port: int} $server + * + * @throws ClawException when the server rejects the title or refuses to open the issue + */ + public function openIssue(array $server, string $key, string $title): int + { + $url = sprintf( + 'http://%s:%d/api/projects/%s/issues', + $server['host'], + $server['port'], + rawurlencode($key), + ); + + $body = json_encode(['title' => $title]); + + if ($body === false) { + throw new ClawException('cannot send the issue title: it is not valid text'); + } + + $response = $this->http->post($url, $body, ['Content-Type: application/json']); + + if ($response->status === 201) { + $data = json_decode($response->body, true); + + return \is_array($data) && isset($data['id']) ? (int) $data['id'] : 0; + } + + if ($response->status === 400) { + throw new ClawException($this->errorText($response) ?? 'the server rejected the issue'); + } + + throw new ClawException("the server refused to open the issue (HTTP {$response->status})"); + } + /** * Send a person's reply to a run's open gate. * diff --git a/tests/ServerClientTest.php b/tests/ServerClientTest.php index 67a3fda..ef6fe6e 100644 --- a/tests/ServerClientTest.php +++ b/tests/ServerClientTest.php @@ -169,6 +169,34 @@ public function anAnswerAcceptedReturnsQuietlyAndCarriesTheText(): void Assert::same(str_contains((string) $http->lastBody, 'use the merge sort'), true); } + #[Test] + public function openingAnIssueReturnsItsIdAndCarriesTheTitle(): void + { + $http = new FakeHttpClient(new HttpResponse(201, '{"id":42,"title":"add isEven","status":"open"}')); + $id = new ServerClient($http, $this->workspace, '/opt/claw') + ->openIssue(['host' => '127.0.0.1', 'port' => 8787], 'proj', 'add isEven'); + + Assert::same($id, 42); + Assert::same(str_ends_with((string) $http->lastUrl, '/api/projects/proj/issues'), true); + Assert::same(str_contains((string) $http->lastBody, 'add isEven'), true); + } + + #[Test] + public function aRejectedIssueSurfacesTheServersReason(): void + { + $threw = false; + + try { + $this->client(new HttpResponse(400, '{"error":"a title is required"}')) + ->openIssue(['host' => '127.0.0.1', 'port' => 8787], 'proj', ''); + } catch (ClawException $e) { + $threw = true; + Assert::same(str_contains($e->getMessage(), 'title is required'), true); + } + + Assert::same($threw, true); + } + #[Test] public function anAnswerNamesTheQuestionItIsFor(): void {