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
60 changes: 18 additions & 42 deletions src/Cli/WorkflowMode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down
38 changes: 38 additions & 0 deletions src/ServerClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
28 changes: 28 additions & 0 deletions tests/ServerClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading