Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ LLM_CONTENT_EDITOR_OPENAI_API_KEY=your-key-here
LLM_WIRE_LOG_ENABLED=0
###< sitebuilder/llm-wire-log ###

CURSOR_AGENT_API_KEY=your-key-here

###> sitebuilder/docker-execution ###
# Host path for Docker-in-Docker volume mounts.
# IMPORTANT: This must be set to the absolute path of the project on the host.
Expand Down
12 changes: 12 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ services:
App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface:
class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade

App\LlmContentEditor\Infrastructure\LlmContentEditorAdapter: ~

App\CursorAgentContentEditor\Infrastructure\CursorAgentContentEditorAdapter: ~

App\AgenticContentEditor\Facade\AgenticContentEditorFacadeInterface:
class: App\AgenticContentEditor\Facade\AgenticContentEditorFacade
arguments:
- [
'@App\LlmContentEditor\Infrastructure\LlmContentEditorAdapter',
'@App\CursorAgentContentEditor\Infrastructure\CursorAgentContentEditorAdapter',
]

# Domain service bindings
App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface:
class: App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuard
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
services:
app:
image: etfs_${ETFS_PROJECT_NAME}_app
build:
context: .
dockerfile: docker/app/Dockerfile
container_name: etfs_${ETFS_PROJECT_NAME}_app
volumes:
- .:/var/www
- mise_data:/opt/mise
- /var/run/docker.sock:/var/run/docker.sock
environment:
# Redirect cache and config directories to /tmp to avoid polluting the mounted project directory
HOME: /tmp/container-home
Expand All @@ -19,13 +21,16 @@ services:
MISE_DATA_DIR: /opt/mise/data
MISE_CACHE_DIR: /opt/mise/cache
MISE_STATE_DIR: /opt/mise/state
# Use the app image (includes Cursor Agent CLI) for agent runs
CURSOR_AGENT_IMAGE: etfs_${ETFS_PROJECT_NAME}_app
networks:
- default
depends_on:
- mariadb
restart: unless-stopped

messenger:
image: etfs_${ETFS_PROJECT_NAME}_app
build:
context: .
dockerfile: docker/app/Dockerfile
Expand All @@ -49,6 +54,8 @@ services:
# The messenger runs Docker commands via the host socket, so paths must be host paths
# Uses PWD as default, which is set by docker-compose to the project directory
HOST_PROJECT_PATH: ${HOST_PROJECT_PATH:-${PWD}}
# Use the app image (includes Cursor Agent CLI) for agent runs
CURSOR_AGENT_IMAGE: etfs_${ETFS_PROJECT_NAME}_app
networks:
- default
depends_on:
Expand Down
3 changes: 3 additions & 0 deletions docker/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ RUN install -m 0755 -d /etc/apt/keyrings && \
apt-get update -y && \
apt-get install -y docker-ce-cli

# Install Cursor CLI for agent execution
RUN curl -fsS https://cursor.com/install | bash

# Clean up to reduce image size
RUN apt-get clean && rm -rf /var/lib/apt/lists/*

Expand Down
34 changes: 30 additions & 4 deletions docs/vertical-wiring.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# Vertical Facade Wiring

This diagram shows **which verticals call which other verticals facade interface methods** — the wiring between verticals only. Internal calls within a vertical are omitted. See [archbook.md](archbook.md) for the overall facade/vertical architecture.
This diagram shows **which verticals call which other verticals' facade interface methods** — the wiring between verticals only. Internal calls within a vertical are omitted. See [archbook.md](archbook.md) for the overall facade/vertical architecture.

```mermaid
flowchart LR
subgraph callers["Callers"]
direction TB
CBCE["ChatBasedContentEditor"]
ACE["AgenticContentEditor"]
LLM["LlmContentEditor"]
CAC["CursorAgentContentEditor"]
WSM["WorkspaceMgmt"]
WST["WorkspaceTooling"]
ORG["Organization"]
Expand All @@ -21,6 +23,7 @@ flowchart LR
ACC[(Account)]
PRJF[(ProjectMgmt)]
WSMF[(WorkspaceMgmt)]
ACEF[(AgenticContentEditor)]
LLMF[(LlmContentEditor)]
WSTF[(WorkspaceTooling)]
RCAF[(RemoteContentAssets)]
Expand All @@ -32,11 +35,16 @@ flowchart LR
CBCE -->|account lookup| ACC
CBCE -->|getProjectInfo| PRJF
CBCE -->|workspace lifecycle, commit, PR| WSMF
CBCE -->|streamEdit, context dump| LLMF
CBCE -->|streamEdit, context dump, model info| ACEF

ACE -.->|dispatches to adapters in| LLM
ACE -.->|dispatches to adapters in| CAC

LLM -->|tools: build, preview, assets, rules| WSTF
LLM -->|getAgentConfigTemplate| PRJF

CAC -->|tools: build, rules| WSTF

WSM -->|getProjectInfo| PRJF
WSM -->|getLatestConversationId| CBCEF

Expand Down Expand Up @@ -65,19 +73,37 @@ Method details are in the summary table below.

| Caller vertical | Calls into (facade) | Main methods |
|---------------------------|----------------------------|--------------|
| **ChatBasedContentEditor** | Account, ProjectMgmt, WorkspaceMgmt, LlmContentEditor | Workspace lifecycle, commitAndPush, streamEditWithHistory, buildAgentContextDump, account resolution |
| **ChatBasedContentEditor** | Account, ProjectMgmt, WorkspaceMgmt, AgenticContentEditor | Workspace lifecycle, commitAndPush, streamEditWithHistory, buildAgentContextDump, getBackendModelInfo, account resolution |
| **AgenticContentEditor** | *(dispatches to adapters)* | Facade dispatches to `LlmContentEditorAdapter` and `CursorAgentContentEditorAdapter` via SPI |
| **LlmContentEditor** | WorkspaceTooling, ProjectMgmt | runQualityChecks, runTests, runBuild, suggestCommitMessage, getPreviewUrl, list/search remote assets, getWorkspaceRules; getAgentConfigTemplate (EditContentCommand) |
| **CursorAgentContentEditor** | WorkspaceTooling | runBuildInWorkspace, runShellCommandAsync, getWorkspaceRules |
| **WorkspaceMgmt** | ProjectMgmt, ChatBasedContentEditor | getProjectInfo (setup, git, review); getLatestConversationId (reviewer UI) |
| **WorkspaceTooling** | RemoteContentAssets | fetchAndMergeAssetUrls, getRemoteAssetInfo |
| **Organization** | Prefab, ProjectMgmt, WorkspaceMgmt, Account | loadPrefabs, createProjectFromPrefab, dispatchSetupIfNeeded; account resolution and registration |
| **ProjectMgmt** (UI) | Account, ChatBasedContentEditor, WorkspaceMgmt, LlmContentEditor, RemoteContentAssets | Project/workspace CRUD, conversation cleanup, key/URL validation |
| **RemoteContentAssets** (UI) | ProjectMgmt | getProjectInfo (for manifest URLs) |
| **Common** (voter) | Account, Organization | getAccountCoreIdByEmail; userCanReviewWorkspaces |

## Architecture: Agentic Content Editor

The **AgenticContentEditor** vertical implements a hexagonal port/adapter pattern:

- **Port** (`AgenticContentEditorFacadeInterface`): what consumers call (e.g. `ChatBasedContentEditor`).
- **SPI** (`AgenticContentEditorAdapterInterface`): what backend adapters implement.
- **Facade** (`AgenticContentEditorFacade`): dispatcher that resolves the correct adapter by backend type.

Adapters live in their respective backend verticals:
- `LlmContentEditor/Infrastructure/LlmContentEditorAdapter` — delegates to `LlmContentEditorFacade`
- `CursorAgentContentEditor/Infrastructure/CursorAgentContentEditorAdapter` — runs the Cursor CLI agent directly

Canonical DTOs and enums (`EditStreamChunkDto`, `AgentConfigDto`, `AgenticContentEditorBackend`, etc.) live in `AgenticContentEditor/Facade/` and are shared by all participants.

## Notes

- **ChatBasedContentEditor** is the main consumer of **WorkspaceMgmt** and **LlmContentEditor** (conversation flow, edit sessions, commit/push).
- **ChatBasedContentEditor** calls **AgenticContentEditor** for all edit operations. It never imports from LlmContentEditor or CursorAgentContentEditor directly.
- **LlmContentEditor** (ContentEditorAgent) uses **WorkspaceTooling** for all tool implementations (quality checks, build, preview, remote assets, rules).
- **CursorAgentContentEditor** uses **WorkspaceTooling** for build and shell execution.
- **WorkspaceTooling** delegates remote asset listing/info to **RemoteContentAssets**.
- **Organization** onboarding (AccountCoreCreatedSymfonyEventSubscriber) wires **Prefab → ProjectMgmt → WorkspaceMgmt** to create projects and dispatch setup.
- **ProjectMgmt** presentation layer coordinates **ChatBasedContentEditor**, **WorkspaceMgmt**, **LlmContentEditor**, and **RemoteContentAssets** for project/workspace/conversation and validation flows.
- **ProjectMgmt** still calls **LlmContentEditor** directly for `verifyApiKey()` — this is intentional as API key verification is LLM-specific and part of project configuration.
33 changes: 33 additions & 0 deletions migrations/Version20260127145023.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260127145023 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE conversations ADD content_editor_backend VARCHAR(32) DEFAULT \'llm\' NOT NULL, ADD cursor_agent_session_id VARCHAR(64) DEFAULT NULL');
$this->addSql('ALTER TABLE projects ADD content_editor_backend VARCHAR(32) DEFAULT \'llm\' NOT NULL');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE conversations DROP content_editor_backend, DROP cursor_agent_session_id');
$this->addSql('ALTER TABLE projects DROP content_editor_backend');
}
}
30 changes: 30 additions & 0 deletions migrations/Version20260211120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Rename cursor_agent_session_id to backend_session_state on conversations.
* Opaque session state is backend-agnostic (e.g. Cursor session ID for Cursor backend).
*/
final class Version20260211120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Rename conversations.cursor_agent_session_id to backend_session_state';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE conversations RENAME COLUMN cursor_agent_session_id TO backend_session_state');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE conversations RENAME COLUMN backend_session_state TO cursor_agent_session_id');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace App\AgenticContentEditor\Facade;

use App\AgenticContentEditor\Facade\Dto\AgentConfigDto;
use App\AgenticContentEditor\Facade\Dto\BackendModelInfoDto;
use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto;
use App\AgenticContentEditor\Facade\Dto\EditStreamChunkDto;
use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend;
use Generator;

/**
* SPI: what backends (LlmContentEditor, CursorAgentContentEditor) implement.
* Adapters yield Done chunks with optional backendSessionState for resumable backends.
*/
interface AgenticContentEditorAdapterInterface
{
public function supports(AgenticContentEditorBackend $backend): bool;

/**
* @param list<ConversationMessageDto> $previousMessages
*
* @return Generator<EditStreamChunkDto>
*/
public function streamEdit(
string $workspacePath,
string $instruction,
array $previousMessages,
string $apiKey,
AgentConfigDto $agentConfig,
?string $backendSessionState = null,
string $locale = 'en',
): Generator;

/**
* Build a human-readable dump of the full agent context for debugging.
* Each backend formats this according to how it actually sends context to its agent.
*
* @param list<ConversationMessageDto> $previousMessages
*/
public function buildAgentContextDump(
string $instruction,
array $previousMessages,
AgentConfigDto $agentConfig
): string;

/**
* Return model information for this backend (name, context limit, cost rates).
* Used for context usage bars and cost estimation in the UI.
*/
public function getBackendModelInfo(): BackendModelInfoDto;
}
87 changes: 87 additions & 0 deletions src/AgenticContentEditor/Facade/AgenticContentEditorFacade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace App\AgenticContentEditor\Facade;

use App\AgenticContentEditor\Facade\Dto\AgentConfigDto;
use App\AgenticContentEditor\Facade\Dto\BackendModelInfoDto;
use App\AgenticContentEditor\Facade\Dto\ConversationMessageDto;
use App\AgenticContentEditor\Facade\Dto\EditStreamChunkDto;
use App\AgenticContentEditor\Facade\Enum\AgenticContentEditorBackend;
use Generator;
use RuntimeException;

/**
* Dispatcher: resolves adapter by backend and delegates. Zero backend-specific logic.
*/
final class AgenticContentEditorFacade implements AgenticContentEditorFacadeInterface
{
/**
* @param list<AgenticContentEditorAdapterInterface> $adapters
*/
public function __construct(
private readonly array $adapters
) {
}

/**
* @param list<ConversationMessageDto> $previousMessages
*
* @return Generator<EditStreamChunkDto>
*/
public function streamEditWithHistory(
AgenticContentEditorBackend $backend,
string $workspacePath,
string $instruction,
array $previousMessages,
string $apiKey,
AgentConfigDto $agentConfig,
?string $backendSessionState = null,
string $locale = 'en',
): Generator {
$adapter = $this->resolveAdapter($backend);

return $adapter->streamEdit(
$workspacePath,
$instruction,
$previousMessages,
$apiKey,
$agentConfig,
$backendSessionState,
$locale
);
}

/**
* @param list<ConversationMessageDto> $previousMessages
*/
public function buildAgentContextDump(
AgenticContentEditorBackend $backend,
string $instruction,
array $previousMessages,
AgentConfigDto $agentConfig
): string {
return $this->resolveAdapter($backend)->buildAgentContextDump(
$instruction,
$previousMessages,
$agentConfig
);
}

public function getBackendModelInfo(AgenticContentEditorBackend $backend): BackendModelInfoDto
{
return $this->resolveAdapter($backend)->getBackendModelInfo();
}

private function resolveAdapter(AgenticContentEditorBackend $backend): AgenticContentEditorAdapterInterface
{
foreach ($this->adapters as $adapter) {
if ($adapter->supports($backend)) {
return $adapter;
}
}

throw new RuntimeException('No content editor adapter registered for backend: ' . $backend->value);
}
}
Loading