Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit 61a3594

Browse files
Switching from Cursor CLI to Claude Code
1 parent 8fb7d3c commit 61a3594

22 files changed

+402
-132
lines changed

src/LlmIntegration/Infrastructure/Service/AgentStreamParser.php

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@
1010
use Symfony\Component\Process\Process;
1111

1212
/**
13-
* Parses NDJSON stream output shared by Cursor CLI and Claude Code CLI agents.
13+
* Orchestrates process execution and NDJSON stream handling: runs the process,
14+
* parses lines via NdjsonStreamLineParser, and logs events / assembles CliAgentRunResult.
1415
*
15-
* Both CLIs emit identical event types: system (init), assistant, tool_call
16-
* (started/completed), and result. This service handles all stream parsing
17-
* and logging for any CLI agent provider.
16+
* Both Cursor CLI and Claude Code CLI emit identical event types: system (init),
17+
* assistant, tool_call (started/completed), and result.
1818
*/
1919
class AgentStreamParser
2020
{
2121
private const float ASSISTANT_MAX_FLUSH_DELAY_SECONDS = 0.6;
2222

2323
public function __construct(
24-
private readonly LoggerInterface $logger,
24+
private readonly NdjsonStreamLineParser $lineParser,
25+
private readonly LoggerInterface $logger,
2526
) {
2627
}
2728

@@ -144,19 +145,28 @@ public function executeAndParseStream(Process $process, string $providerLabel):
144145
}
145146

146147
/**
147-
* Parses a single NDJSON line, logs a human-readable summary, and returns the decoded data.
148+
* Parses a single NDJSON line (via line parser), logs a human-readable summary, and returns the decoded data.
148149
*
149150
* @return array<string, mixed>|null
150151
*/
151152
private function logStreamEvent(string $jsonLine, string $providerLabel): ?array
152153
{
154+
$parsed = $this->lineParser->parseLine($jsonLine);
155+
if ($parsed === null) {
156+
return null;
157+
}
158+
153159
try {
154-
/** @var array<string, mixed> $event */
155-
$event = json_decode($jsonLine, true, 512, JSON_THROW_ON_ERROR);
160+
/** @var array<string, mixed>|null $event */
161+
$event = json_decode($parsed->jsonLine, true, 512, JSON_THROW_ON_ERROR);
156162
} catch (JsonException) {
157163
return null;
158164
}
159165

166+
if (!is_array($event)) {
167+
return null;
168+
}
169+
160170
$type = is_string($event['type'] ?? null) ? $event['type'] : '';
161171
$subtype = is_string($event['subtype'] ?? null) ? $event['subtype'] : '';
162172

@@ -481,18 +491,30 @@ private function shouldFlushAssistantBufferByBoundary(string $assistantLogBuffer
481491

482492
private function parseResultLine(string $jsonLine, string $providerLabel): CliAgentRunResult
483493
{
494+
$parsed = $this->lineParser->parseLine($jsonLine);
495+
if ($parsed === null) {
496+
$this->logger->warning('[' . $providerLabel . '] Could not parse result JSON line', [
497+
'preview' => mb_substr($jsonLine, 0, 500),
498+
]);
499+
500+
return new CliAgentRunResult(true, $jsonLine, null, null);
501+
}
502+
484503
try {
485-
/** @var array<string, mixed> $data */
486-
$data = json_decode($jsonLine, true, 512, JSON_THROW_ON_ERROR);
487-
} catch (JsonException $e) {
504+
/** @var array<string, mixed>|null $data */
505+
$data = json_decode($parsed->jsonLine, true, 512, JSON_THROW_ON_ERROR);
506+
} catch (JsonException) {
488507
$this->logger->warning('[' . $providerLabel . '] Could not parse result JSON line', [
489-
'error' => $e->getMessage(),
490508
'preview' => mb_substr($jsonLine, 0, 500),
491509
]);
492510

493511
return new CliAgentRunResult(true, $jsonLine, null, null);
494512
}
495513

514+
if (!is_array($data)) {
515+
return new CliAgentRunResult(true, $jsonLine, null, null);
516+
}
517+
496518
$isError = (bool) ($data['is_error'] ?? false);
497519
$resultText = is_string($data['result'] ?? null) ? $data['result'] : $jsonLine;
498520
$sessionId = is_string($data['session_id'] ?? null) ? $data['session_id'] : null;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\LlmIntegration\Infrastructure\Service\Dto;
6+
7+
/**
8+
* DTO for a single NDJSON stream line (system, assistant, tool_call, result).
9+
* Carries the raw line across the boundary; decoding happens in the consumer.
10+
*/
11+
readonly class ParsedStreamEvent
12+
{
13+
public function __construct(
14+
public string $jsonLine,
15+
) {
16+
}
17+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\LlmIntegration\Infrastructure\Service;
6+
7+
use App\LlmIntegration\Infrastructure\Service\Dto\ParsedStreamEvent;
8+
9+
/**
10+
* Delivers a single NDJSON line as a DTO for the stream parser.
11+
* No I/O, no logging — used by AgentStreamParser to separate line delivery from decoding/orchestration.
12+
*/
13+
final class NdjsonStreamLineParser
14+
{
15+
public function parseLine(string $jsonLine): ?ParsedStreamEvent
16+
{
17+
$trimmed = trim($jsonLine);
18+
if ($trimmed === '') {
19+
return null;
20+
}
21+
22+
return new ParsedStreamEvent($trimmed);
23+
}
24+
}

src/Workflow/Infrastructure/Service/ImplementationRunStarter.php

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,23 @@
44

55
namespace App\Workflow\Infrastructure\Service;
66

7-
use App\GithubIntegration\Facade\Enum\GithubLabel;
8-
use App\GithubIntegration\Facade\GithubIntegrationFacadeInterface;
97
use App\Workflow\Facade\Enum\WorkflowRunPhase;
10-
use App\Workflow\Facade\WorkflowConcurrencyFacadeInterface;
11-
use Symfony\Component\Uid\Uuid;
12-
use Throwable;
138

149
readonly class ImplementationRunStarter implements ImplementationRunStarterInterface
1510
{
1611
public function __construct(
17-
private WorkflowConcurrencyFacadeInterface $workflowConcurrencyFacade,
18-
private GithubIntegrationFacadeInterface $githubIntegrationFacade,
12+
private PhaseRunStarter $phaseRunStarter,
1913
) {
2014
}
2115

2216
public function startImplementation(string $githubUrl, string $githubToken, string $productConfigId, int $issueNumber): ?string
2317
{
24-
$runId = Uuid::v4()->toRfc4122();
25-
if (!$this->workflowConcurrencyFacade->tryCreateRunClaim($productConfigId, $issueNumber, WorkflowRunPhase::Implementation, $runId)) {
26-
return null;
27-
}
28-
29-
try {
30-
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::CoordinationOngoing);
31-
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::ImplementationOngoing);
32-
} catch (Throwable $e) {
33-
$this->workflowConcurrencyFacade->releaseRunClaim($productConfigId, $issueNumber, $runId);
34-
throw $e;
35-
}
36-
37-
return $runId;
18+
return $this->phaseRunStarter->startRun(
19+
WorkflowRunPhase::Implementation,
20+
$githubUrl,
21+
$githubToken,
22+
$productConfigId,
23+
$issueNumber,
24+
);
3825
}
3926
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Workflow\Infrastructure\Service;
6+
7+
use App\GithubIntegration\Facade\Enum\GithubLabel;
8+
use App\GithubIntegration\Facade\GithubIntegrationFacadeInterface;
9+
use App\Workflow\Facade\Enum\WorkflowRunPhase;
10+
use App\Workflow\Facade\WorkflowConcurrencyFacadeInterface;
11+
use Symfony\Component\Uid\Uuid;
12+
use Throwable;
13+
14+
/**
15+
* Single place for "create run claim + add phase labels + release on failure".
16+
* Used by ImplementationRunStarter and PlanningRunStarter.
17+
*/
18+
readonly class PhaseRunStarter
19+
{
20+
public function __construct(
21+
private WorkflowConcurrencyFacadeInterface $workflowConcurrencyFacade,
22+
private GithubIntegrationFacadeInterface $githubIntegrationFacade,
23+
) {
24+
}
25+
26+
public function startRun(
27+
WorkflowRunPhase $phase,
28+
string $githubUrl,
29+
string $githubToken,
30+
string $productConfigId,
31+
int $issueNumber,
32+
): ?string {
33+
$runId = Uuid::v4()->toRfc4122();
34+
if (!$this->workflowConcurrencyFacade->tryCreateRunClaim($productConfigId, $issueNumber, $phase, $runId)) {
35+
return null;
36+
}
37+
38+
try {
39+
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::CoordinationOngoing);
40+
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, $this->phaseOngoingLabel($phase));
41+
} catch (Throwable $e) {
42+
$this->workflowConcurrencyFacade->releaseRunClaim($productConfigId, $issueNumber, $runId);
43+
throw $e;
44+
}
45+
46+
return $runId;
47+
}
48+
49+
private function phaseOngoingLabel(WorkflowRunPhase $phase): GithubLabel
50+
{
51+
return match ($phase) {
52+
WorkflowRunPhase::Planning => GithubLabel::PlanningOngoing,
53+
WorkflowRunPhase::Implementation => GithubLabel::ImplementationOngoing,
54+
};
55+
}
56+
}

src/Workflow/Infrastructure/Service/PlanningRunStarter.php

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,23 @@
44

55
namespace App\Workflow\Infrastructure\Service;
66

7-
use App\GithubIntegration\Facade\Enum\GithubLabel;
8-
use App\GithubIntegration\Facade\GithubIntegrationFacadeInterface;
97
use App\Workflow\Facade\Enum\WorkflowRunPhase;
10-
use App\Workflow\Facade\WorkflowConcurrencyFacadeInterface;
11-
use Symfony\Component\Uid\Uuid;
12-
use Throwable;
138

149
readonly class PlanningRunStarter implements PlanningRunStarterInterface
1510
{
1611
public function __construct(
17-
private WorkflowConcurrencyFacadeInterface $workflowConcurrencyFacade,
18-
private GithubIntegrationFacadeInterface $githubIntegrationFacade,
12+
private PhaseRunStarter $phaseRunStarter,
1913
) {
2014
}
2115

2216
public function startPlanning(string $githubUrl, string $githubToken, string $productConfigId, int $issueNumber): ?string
2317
{
24-
$runId = Uuid::v4()->toRfc4122();
25-
if (!$this->workflowConcurrencyFacade->tryCreateRunClaim($productConfigId, $issueNumber, WorkflowRunPhase::Planning, $runId)) {
26-
return null;
27-
}
28-
29-
try {
30-
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::CoordinationOngoing);
31-
$this->githubIntegrationFacade->addLabelToIssue($githubUrl, $githubToken, $issueNumber, GithubLabel::PlanningOngoing);
32-
} catch (Throwable $e) {
33-
$this->workflowConcurrencyFacade->releaseRunClaim($productConfigId, $issueNumber, $runId);
34-
throw $e;
35-
}
36-
37-
return $runId;
18+
return $this->phaseRunStarter->startRun(
19+
WorkflowRunPhase::Planning,
20+
$githubUrl,
21+
$githubToken,
22+
$productConfigId,
23+
$issueNumber,
24+
);
3825
}
3926
}

src/WorkspaceManagement/Facade/WorkspaceManagementFacade.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66

77
use App\WorkspaceManagement\Facade\Dto\WorkspaceInfoDto;
88
use App\WorkspaceManagement\Facade\Dto\WorkspaceInfoLookupDto;
9-
use App\WorkspaceManagement\Infrastructure\Service\GitCloneService;
9+
use App\WorkspaceManagement\Infrastructure\Service\WorkspaceAcquisitionInterface;
1010
use App\WorkspaceManagement\Infrastructure\Service\WorkspaceInfoLookupService;
1111
use App\WorkspaceManagement\Infrastructure\Service\WorkspaceSweepCleanupService;
1212

1313
readonly class WorkspaceManagementFacade implements WorkspaceManagementFacadeInterface
1414
{
1515
public function __construct(
16-
private GitCloneService $gitCloneService,
17-
private WorkspaceSweepCleanupService $workspaceSweepCleanupService,
18-
private WorkspaceInfoLookupService $workspaceInfoLookupService,
16+
private WorkspaceAcquisitionInterface $workspaceAcquisition,
17+
private WorkspaceSweepCleanupService $workspaceSweepCleanupService,
18+
private WorkspaceInfoLookupService $workspaceInfoLookupService,
1919
) {
2020
}
2121

@@ -26,7 +26,7 @@ public function acquireWorkspace(
2626
string $githubToken,
2727
?string $branchName = null,
2828
): WorkspaceInfoDto {
29-
return $this->gitCloneService->acquireWorkspace(
29+
return $this->workspaceAcquisition->acquireWorkspace(
3030
$productConfigId,
3131
$issueNumber,
3232
$githubUrl,
@@ -37,12 +37,12 @@ public function acquireWorkspace(
3737

3838
public function releaseWorkspace(WorkspaceInfoDto $workspaceInfo): void
3939
{
40-
$this->gitCloneService->releaseWorkspace($workspaceInfo->workspacePath);
40+
$this->workspaceAcquisition->releaseWorkspace($workspaceInfo->workspacePath);
4141
}
4242

4343
public function destroyWorkspace(WorkspaceInfoDto $workspaceInfo): void
4444
{
45-
$this->gitCloneService->removeWorkspace($workspaceInfo->workspacePath, $workspaceInfo->containerName);
45+
$this->workspaceAcquisition->removeWorkspace($workspaceInfo->workspacePath, $workspaceInfo->containerName);
4646
}
4747

4848
public function cleanupWorkspaceForIssue(string $productConfigId, int $issueNumber): void
@@ -65,11 +65,11 @@ public function lookupWorkspaceInfoByPrefix(string $workspaceIdPrefix): array
6565

6666
public function createWorkspace(string $githubUrl, string $githubToken): WorkspaceInfoDto
6767
{
68-
return $this->gitCloneService->cloneRepository($githubUrl, $githubToken);
68+
return $this->workspaceAcquisition->cloneRepository($githubUrl, $githubToken);
6969
}
7070

7171
public function createWorkspaceOnBranch(string $githubUrl, string $githubToken, string $branchName): WorkspaceInfoDto
7272
{
73-
return $this->gitCloneService->cloneRepositoryOnBranch($githubUrl, $githubToken, $branchName);
73+
return $this->workspaceAcquisition->cloneRepositoryOnBranch($githubUrl, $githubToken, $branchName);
7474
}
7575
}

0 commit comments

Comments
 (0)