From 37883bbaceea0aee0c599c39fc0793c966fbbf11 Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Thu, 12 Feb 2026 16:18:20 +0100 Subject: [PATCH 1/4] Issue #95 - Implement debugging functionality --- .../ChatBasedContentEditorController.php | 16 +++++++++++++++- .../Facade/WorkspaceToolingFacade.php | 3 ++- .../Infrastructure/Execution/DockerExecutor.php | 12 +++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index ff329430..0f210228 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -24,6 +24,7 @@ use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus; use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use RuntimeException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -60,6 +61,7 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly PromptSuggestionsService $promptSuggestionsService, private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade, + private readonly LoggerInterface $logger, ) { } @@ -819,7 +821,13 @@ public function savePage( $this->workspaceMgmtFacade->writeWorkspaceFile($workspaceId, $sourcePath, $content); // Run build to update dist/ from src/ - $this->workspaceMgmtFacade->runBuild($workspaceId); + $buildOutput = $this->workspaceMgmtFacade->runBuild($workspaceId); + + $this->logger->info('HTML editor build completed', [ + 'workspaceId' => $workspaceId, + 'sourcePath' => $sourcePath, + 'buildOutput' => $buildOutput, + ]); // Get user email for commit author $accountInfo = $this->getAccountInfo($user); @@ -833,6 +841,12 @@ public function savePage( return $this->json(['success' => true]); } catch (Throwable $e) { + $this->logger->error('HTML editor save failed', [ + 'workspaceId' => $workspaceId, + 'sourcePath' => $sourcePath, + 'error' => $e->getMessage(), + ]); + return $this->json(['error' => 'Failed to save file: ' . $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); } } diff --git a/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php b/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php index cb41129a..0cbde63a 100644 --- a/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php +++ b/src/WorkspaceTooling/Facade/WorkspaceToolingFacade.php @@ -237,7 +237,8 @@ public function runBuildInWorkspace(string $workspacePath, string $agentImage): '/workspace', 300, true, - 'html-editor-build' + 'html-editor-build', + true ); } } diff --git a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php index 59fcc682..e22e5f66 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php @@ -39,6 +39,7 @@ public function __construct( * @param int $timeout Timeout in seconds * @param bool $allowNetwork Whether to allow network access * @param string|null $containerName Optional container name for identification + * @param bool $throwOnFailure When true, throw on ANY non-zero exit code (not just Docker errors >= 125) * * @return string Combined stdout and stderr output * @@ -51,7 +52,8 @@ public function run( string $workingDirectory = '/workspace', int $timeout = self::DEFAULT_TIMEOUT, bool $allowNetwork = true, - ?string $containerName = null + ?string $containerName = null, + bool $throwOnFailure = false ): string { $dockerCommand = $this->buildDockerCommand( $image, @@ -96,6 +98,14 @@ public function run( ); } + // In strict mode, throw on ANY non-zero exit code (used by HTML editor build etc.) + if ($throwOnFailure) { + throw new DockerExecutionException( + sprintf('Command failed with exit code %d: %s', $exitCode ?? -1, $errorOutput), + $command + ); + } + // Return output even on non-zero exit - the command may have legitimate non-zero exits // Only throw for Docker-level failures (exit code 125-127 are Docker errors) if ($exitCode !== null && $exitCode >= 125) { From 304a3aad3666214acf9e234711f105f29e3eb7bb Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Mon, 16 Feb 2026 16:00:25 +0100 Subject: [PATCH 2/4] Issue #95 - Implemented fix by running mise --- .../Facade/WorkspaceMgmtFacade.php | 37 +++++++++++-------- .../Facade/WorkspaceMgmtFacadeInterface.php | 5 ++- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php index cc4de399..253b08c0 100644 --- a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php +++ b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacade.php @@ -14,11 +14,11 @@ use App\WorkspaceMgmt\Infrastructure\Adapter\FilesystemAdapterInterface; use App\WorkspaceMgmt\Infrastructure\Message\SetupWorkspaceMessage; use App\WorkspaceMgmt\Infrastructure\Service\GitHubUrlServiceInterface; -use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface; use Doctrine\ORM\EntityManagerInterface; use RuntimeException; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Process\Process; use Throwable; use function str_starts_with; @@ -31,16 +31,15 @@ final class WorkspaceMgmtFacade implements WorkspaceMgmtFacadeInterface { public function __construct( #[Autowire(param: 'workspace_mgmt.workspace_root')] - private readonly string $workspaceRoot, - private readonly WorkspaceService $workspaceService, - private readonly WorkspaceGitService $gitService, - private readonly WorkspaceStatusGuard $statusGuard, - private readonly ProjectMgmtFacadeInterface $projectMgmtFacade, - private readonly EntityManagerInterface $entityManager, - private readonly MessageBusInterface $messageBus, - private readonly GitHubUrlServiceInterface $gitHubUrlService, - private readonly FilesystemAdapterInterface $filesystemAdapter, - private readonly WorkspaceToolingServiceInterface $workspaceToolingService, + private readonly string $workspaceRoot, + private readonly WorkspaceService $workspaceService, + private readonly WorkspaceGitService $gitService, + private readonly WorkspaceStatusGuard $statusGuard, + private readonly ProjectMgmtFacadeInterface $projectMgmtFacade, + private readonly EntityManagerInterface $entityManager, + private readonly MessageBusInterface $messageBus, + private readonly GitHubUrlServiceInterface $gitHubUrlService, + private readonly FilesystemAdapterInterface $filesystemAdapter, ) { } @@ -242,14 +241,20 @@ public function writeWorkspaceFile(string $workspaceId, string $relativePath, st public function runBuild(string $workspaceId): string { - $workspace = $this->getWorkspaceOrFail($workspaceId); - - // Get project info to determine the agent image - $projectInfo = $this->projectMgmtFacade->getProjectInfo($workspace->getProjectId()); + $this->getWorkspaceOrFail($workspaceId); $workspacePath = $this->workspaceRoot . '/' . $workspaceId; - return $this->workspaceToolingService->runBuildInWorkspace($workspacePath, $projectInfo->agentImage); + // Run build via mise exec (same approach as workspace setup). + // This executes directly in the app container using the Node.js + // version configured in the workspace's .mise.toml, without + // requiring Docker socket access. + $process = new Process(['mise', 'exec', '--', 'npm', 'run', 'build']); + $process->setWorkingDirectory($workspacePath); + $process->setTimeout(300); + $process->mustRun(); + + return $process->getOutput(); } /** diff --git a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php index d4a3999d..ea77077b 100644 --- a/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php +++ b/src/WorkspaceMgmt/Facade/WorkspaceMgmtFacadeInterface.php @@ -121,14 +121,17 @@ public function readWorkspaceFile(string $workspaceId, string $relativePath): st public function writeWorkspaceFile(string $workspaceId, string $relativePath, string $content): void; /** - * Run the build process (npm run build) in the workspace. + * Run the build process (npm run build) in the workspace via mise exec. * * This compiles source files (from /src) to distribution files (to /dist). * Used after manual HTML edits to update the dist folder. + * Executes directly using mise (no Docker socket required). * * @param string $workspaceId the workspace ID * * @return string the build output + * + * @throws \Symfony\Component\Process\Exception\ProcessFailedException if the build fails */ public function runBuild(string $workspaceId): string; } From 569069c2d4ed90de001057dcd1a4a9a0600d4e22 Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Wed, 18 Feb 2026 16:13:12 +0100 Subject: [PATCH 3/4] Issue #95 - Changes coming from the HTML editor are now being built correctly by using an asynchronous HTML build process via symfony message --- .../ChatBasedContentEditorController.php | 56 ++++++--- .../controllers/html_editor_controller.ts | 112 ++++++++++++++---- .../templates/chat_based_content_editor.twig | 3 + .../Execution/DockerExecutor.php | 22 +++- .../WorkspaceToolingFacadeTest.php | 5 +- translations/messages.de.yaml | 4 +- translations/messages.en.yaml | 4 +- 7 files changed, 164 insertions(+), 42 deletions(-) diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index f5e3b272..ff97c591 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -9,11 +9,13 @@ use App\ChatBasedContentEditor\Domain\Entity\Conversation; use App\ChatBasedContentEditor\Domain\Entity\EditSession; use App\ChatBasedContentEditor\Domain\Entity\EditSessionChunk; +use App\ChatBasedContentEditor\Domain\Entity\HtmlEditorBuild; use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; use App\ChatBasedContentEditor\Domain\Enum\EditSessionStatus; use App\ChatBasedContentEditor\Domain\Service\ConversationService; use App\ChatBasedContentEditor\Infrastructure\Adapter\DistFileScannerInterface; use App\ChatBasedContentEditor\Infrastructure\Message\RunEditSessionMessage; +use App\ChatBasedContentEditor\Infrastructure\Message\RunHtmlBuildMessage; use App\ChatBasedContentEditor\Presentation\Service\ConversationContextUsageService; use App\ChatBasedContentEditor\Presentation\Service\PromptSuggestionsService; use App\LlmContentEditor\Facade\Dto\AgentConfigDto; @@ -820,26 +822,30 @@ public function savePage( // Write the file to src/ $this->workspaceMgmtFacade->writeWorkspaceFile($workspaceId, $sourcePath, $content); - // Run build to update dist/ from src/ - $buildOutput = $this->workspaceMgmtFacade->runBuild($workspaceId); + // Get user email for commit author + $accountInfo = $this->getAccountInfo($user); + + // Create a build record and dispatch async build via Messenger. + // The messenger container has Docker socket access required for + // running the build in the project's agent image. + $build = new HtmlEditorBuild($workspaceId, $sourcePath, $accountInfo->email); + $this->entityManager->persist($build); + $this->entityManager->flush(); + + $buildId = $build->getId(); + if ($buildId === null) { + return $this->json(['error' => 'Failed to create build record.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $this->messageBus->dispatch(new RunHtmlBuildMessage($buildId)); - $this->logger->info('HTML editor build completed', [ + $this->logger->info('HTML editor build dispatched', [ + 'buildId' => $buildId, 'workspaceId' => $workspaceId, 'sourcePath' => $sourcePath, - 'buildOutput' => $buildOutput, ]); - // Get user email for commit author - $accountInfo = $this->getAccountInfo($user); - - // Commit and push the changes (now includes both src/ and rebuilt dist/) - $this->workspaceMgmtFacade->commitAndPush( - $workspaceId, - 'Manual HTML edit: ' . $sourcePath, - $accountInfo->email - ); - - return $this->json(['success' => true]); + return $this->json(['buildId' => $buildId], Response::HTTP_ACCEPTED); } catch (Throwable $e) { $this->logger->error('HTML editor save failed', [ 'workspaceId' => $workspaceId, @@ -851,6 +857,26 @@ public function savePage( } } + #[Route( + path: '/workspace/{workspaceId}/build-status/{buildId}', + name: 'chat_based_content_editor.presentation.build_status', + methods: [Request::METHOD_GET], + requirements: ['workspaceId' => '[a-f0-9-]{36}', 'buildId' => '[a-f0-9-]{36}'] + )] + public function pollBuildStatus(string $workspaceId, string $buildId): Response + { + $build = $this->entityManager->find(HtmlEditorBuild::class, $buildId); + + if ($build === null || $build->getWorkspaceId() !== $workspaceId) { + return $this->json(['error' => 'Build not found.'], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'status' => $build->getStatus()->value, + 'error' => $build->getErrorMessage(), + ]); + } + /** * Map a dist/ path to the corresponding src/ path. * diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/html_editor_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/html_editor_controller.ts index b3ea61fa..5643ce3d 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/html_editor_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/html_editor_controller.ts @@ -5,19 +5,35 @@ interface TranslationsData { saveChanges: string; close: string; saving: string; + building: string; saveSuccess: string; saveError: string; + buildError: string; loadError: string; } +interface SaveResponse { + buildId?: string; + error?: string; +} + +interface BuildStatusResponse { + status: string; + error: string | null; +} + /** * Stimulus controller for the HTML editor. * Allows users to edit HTML content of dist files directly. + * + * After saving, an async build is dispatched via Symfony Messenger. + * The controller polls the build status endpoint until completed or failed. */ export default class extends Controller { static values = { loadUrl: String, saveUrl: String, + buildStatusUrlTemplate: String, workspaceId: String, translations: Object, }; @@ -36,6 +52,7 @@ export default class extends Controller { declare readonly loadUrlValue: string; declare readonly saveUrlValue: string; + declare readonly buildStatusUrlTemplateValue: string; declare readonly workspaceIdValue: string; declare readonly translationsValue: TranslationsData; @@ -60,15 +77,19 @@ export default class extends Controller { private currentPath: string = ""; private csrfToken: string = ""; + private buildPollTimeoutId: ReturnType | null = null; connect(): void { - // Get CSRF token from the page const csrfInput = document.querySelector('input[name="_html_editor_csrf_token"]'); if (csrfInput) { this.csrfToken = csrfInput.value; } } + disconnect(): void { + this.stopBuildPolling(); + } + /** * Open the HTML editor for a specific file. * Called via custom event from dist_files_controller. @@ -84,7 +105,6 @@ export default class extends Controller { this.hideError(); this.hideSuccess(); - // Slide up chat area and slide down editor container if (this.hasChatAreaTarget) { this.chatAreaTarget.classList.remove("grid-rows-[1fr]"); this.chatAreaTarget.classList.add("grid-rows-[0fr]"); @@ -94,9 +114,7 @@ export default class extends Controller { this.containerTarget.classList.add("grid-rows-[1fr]"); } - // Update page name display - show source path with mapping info if (this.hasPageNameDisplayTarget) { - // Map dist/ path to src/ path for display const sourcePath = path.startsWith("dist/") ? "src/" + path.substring(5) : path; this.pageNameDisplayTarget.textContent = `${sourcePath}`; } @@ -128,7 +146,7 @@ export default class extends Controller { } /** - * Save the HTML changes. + * Save the HTML changes and start polling for build completion. */ async saveChanges(): Promise { if (!this.currentPath || !this.hasTextareaTarget) { @@ -137,7 +155,7 @@ export default class extends Controller { this.hideError(); this.hideSuccess(); - this.setSaving(true); + this.setBusy(true, this.translationsValue.saving); const formData = new FormData(); formData.append("path", this.currentPath); @@ -151,37 +169,39 @@ export default class extends Controller { body: formData, }); - const data = (await response.json()) as { success?: boolean; error?: string }; + const data = (await response.json()) as SaveResponse; if (!response.ok || data.error) { this.showError(data.error || this.translationsValue.saveError); - this.setSaving(false); + this.setBusy(false); return; } - this.showSuccess(this.translationsValue.saveSuccess); - this.setSaving(false); + if (data.buildId) { + this.setBusy(true, this.translationsValue.building); + this.pollBuildStatus(data.buildId); + } else { + this.showSuccess(this.translationsValue.saveSuccess); + this.setBusy(false); + } } catch (err) { const msg = err instanceof Error ? err.message : this.translationsValue.saveError; this.showError(msg); - this.setSaving(false); + this.setBusy(false); } } - /** - * Close the editor without saving. - */ closeEditor(): void { this.currentPath = ""; this.hideError(); this.hideSuccess(); + this.stopBuildPolling(); if (this.hasTextareaTarget) { this.textareaTarget.value = ""; } - // Slide up editor container and slide down chat area if (this.hasContainerTarget) { this.containerTarget.classList.remove("grid-rows-[1fr]"); this.containerTarget.classList.add("grid-rows-[0fr]"); @@ -192,6 +212,58 @@ export default class extends Controller { } } + private pollBuildStatus(buildId: string): void { + this.stopBuildPolling(); + + const url = this.buildStatusUrlTemplateValue.replace("00000000-0000-0000-0000-000000000000", buildId); + + const doPoll = async (): Promise => { + try { + const response = await fetch(url, { + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + + if (!response.ok) { + this.showError(this.translationsValue.buildError); + this.setBusy(false); + + return; + } + + const data = (await response.json()) as BuildStatusResponse; + + if (data.status === "completed") { + this.showSuccess(this.translationsValue.saveSuccess); + this.setBusy(false); + + return; + } + + if (data.status === "failed") { + this.showError(data.error || this.translationsValue.buildError); + this.setBusy(false); + + return; + } + + // Still pending or running -- schedule next poll + this.buildPollTimeoutId = setTimeout(() => void doPoll(), 1000); + } catch { + this.showError(this.translationsValue.buildError); + this.setBusy(false); + } + }; + + void doPoll(); + } + + private stopBuildPolling(): void { + if (this.buildPollTimeoutId !== null) { + clearTimeout(this.buildPollTimeoutId); + this.buildPollTimeoutId = null; + } + } + private showLoading(): void { if (this.hasLoadingTarget) { this.loadingTarget.classList.remove("hidden"); @@ -236,15 +308,15 @@ export default class extends Controller { } } - private setSaving(saving: boolean): void { + private setBusy(busy: boolean, buttonText?: string): void { if (this.hasSaveButtonTarget) { - this.saveButtonTarget.disabled = saving; - this.saveButtonTarget.textContent = saving - ? this.translationsValue.saving + this.saveButtonTarget.disabled = busy; + this.saveButtonTarget.textContent = busy + ? (buttonText ?? this.translationsValue.saving) : this.translationsValue.saveChanges; } if (this.hasCloseButtonTarget) { - this.closeButtonTarget.disabled = saving; + this.closeButtonTarget.disabled = busy; } } } diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig index dc4af916..a5744c55 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -140,14 +140,17 @@ {{ stimulus_controller('html-editor', { loadUrl: path('chat_based_content_editor.presentation.page_content', { workspaceId: workspace.id }), saveUrl: path('chat_based_content_editor.presentation.save_page', { workspaceId: workspace.id }), + buildStatusUrlTemplate: path('chat_based_content_editor.presentation.build_status', { workspaceId: workspace.id, buildId: '00000000-0000-0000-0000-000000000000' }), workspaceId: workspace.id, translations: { editing: 'editor.html_editor_editing'|trans, saveChanges: 'editor.html_editor_save_changes'|trans, close: 'editor.html_editor_close'|trans, saving: 'editor.html_editor_saving'|trans, + building: 'editor.html_editor_building'|trans, saveSuccess: 'editor.html_editor_save_success'|trans, saveError: 'editor.html_editor_save_error'|trans, + buildError: 'editor.html_editor_build_error'|trans, loadError: 'editor.html_editor_load_error'|trans } }) }} diff --git a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php index e22e5f66..de543bc5 100644 --- a/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php +++ b/src/WorkspaceTooling/Infrastructure/Execution/DockerExecutor.php @@ -4,6 +4,7 @@ namespace App\WorkspaceTooling\Infrastructure\Execution; +use Psr\Log\LoggerInterface; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; @@ -24,8 +25,9 @@ final class DockerExecutor private const int DEFAULT_TIMEOUT = 300; // 5 minutes public function __construct( - private readonly string $containerBasePath, - private readonly string $hostBasePath + private readonly string $containerBasePath, + private readonly string $hostBasePath, + private readonly LoggerInterface $logger, ) { } @@ -67,6 +69,14 @@ public function run( $process = new Process($dockerCommand); $process->setTimeout($timeout); + $this->logger->info('Executing Docker command', [ + 'command' => implode(' ', $dockerCommand), + 'image' => $image, + 'mountPath' => $mountPath, + 'hostBasePath' => $this->hostBasePath, + 'containerName' => $containerName, + ]); + try { $process->run(); } catch (ProcessTimedOutException $e) { @@ -80,10 +90,16 @@ public function run( $output = $process->getOutput() . $process->getErrorOutput(); if (!$process->isSuccessful()) { - // Check for common Docker errors $exitCode = $process->getExitCode(); $errorOutput = $process->getErrorOutput(); + $this->logger->error('Docker command failed', [ + 'exitCode' => $exitCode, + 'stderr' => $errorOutput, + 'stdout' => $process->getOutput(), + 'command' => implode(' ', $dockerCommand), + ]); + if (str_contains($errorOutput, 'Unable to find image')) { throw new DockerExecutionException( sprintf('Docker image not found: %s', $image), diff --git a/tests/Unit/WorkspaceTooling/WorkspaceToolingFacadeTest.php b/tests/Unit/WorkspaceTooling/WorkspaceToolingFacadeTest.php index 29f7f2c1..acad09bf 100644 --- a/tests/Unit/WorkspaceTooling/WorkspaceToolingFacadeTest.php +++ b/tests/Unit/WorkspaceTooling/WorkspaceToolingFacadeTest.php @@ -13,6 +13,7 @@ use EtfsCodingAgent\Service\ShellOperationsServiceInterface; use EtfsCodingAgent\Service\TextOperationsService; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; final class WorkspaceToolingFacadeTest extends TestCase { @@ -411,7 +412,7 @@ private function createFacade(): WorkspaceToolingFacade $remoteContentAssetsFacade = $this->createMock(RemoteContentAssetsFacadeInterface::class); // DockerExecutor is final, so we create a real instance with dummy paths // The tests don't call runBuildInWorkspace, so this is safe - $dockerExecutor = new DockerExecutor('/tmp', '/tmp'); + $dockerExecutor = new DockerExecutor('/tmp', '/tmp', new NullLogger()); return new WorkspaceToolingFacade($fileOps, $textOps, $shellOps, $this->executionContext, $remoteContentAssetsFacade, $dockerExecutor); } @@ -459,7 +460,7 @@ private function createFacadeWithRemoteContentAssets(RemoteContentAssetsFacadeIn $shellOps = $this->createMock(ShellOperationsServiceInterface::class); // DockerExecutor is final, so we create a real instance with dummy paths // The tests don't call runBuildInWorkspace, so this is safe - $dockerExecutor = new DockerExecutor('/tmp', '/tmp'); + $dockerExecutor = new DockerExecutor('/tmp', '/tmp', new NullLogger()); return new WorkspaceToolingFacade($fileOps, $textOps, $shellOps, $this->executionContext, $remoteContentAssetsFacade, $dockerExecutor); } diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 15df9bc0..94033f6a 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -186,8 +186,10 @@ editor: html_editor_save_changes: "Änderungen speichern" html_editor_close: "Editor schließen" html_editor_saving: "Speichern..." - html_editor_save_success: "Änderungen erfolgreich gespeichert" + html_editor_building: "Wird gebaut..." + html_editor_save_success: "Änderungen erfolgreich gespeichert und gebaut" html_editor_save_error: "Fehler beim Speichern der Änderungen" + html_editor_build_error: "Build fehlgeschlagen" html_editor_load_error: "Fehler beim Laden des Seiteninhalts" working_on: "Arbeitet an %project%" description: "Beschreiben Sie, was Sie ändern möchten, in einfacher Sprache. Der Assistent kümmert sich um die Änderungen und zeigt den Fortschritt an." diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index b1332b40..965bad16 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -186,8 +186,10 @@ editor: html_editor_save_changes: "Save changes" html_editor_close: "Close editor" html_editor_saving: "Saving..." - html_editor_save_success: "Changes saved successfully" + html_editor_building: "Building..." + html_editor_save_success: "Changes saved and built successfully" html_editor_save_error: "Failed to save changes" + html_editor_build_error: "Build failed" html_editor_load_error: "Failed to load page content" working_on: "Working on %project%" description: "Describe what you want changed in plain language. The assistant will take care of the edits and show progress." From f470e8f184857651b110ecebc0665cfb600531ae Mon Sep 17 00:00:00 2001 From: Dennis Schramm Date: Thu, 19 Feb 2026 10:32:37 +0100 Subject: [PATCH 4/4] Issue #95 - Added missing files from repository --- migrations/Version20260218143103.php | 31 +++++ .../Domain/Entity/HtmlEditorBuild.php | 127 ++++++++++++++++++ .../Domain/Enum/HtmlEditorBuildStatus.php | 13 ++ .../Handler/RunHtmlBuildHandler.php | 86 ++++++++++++ .../Message/RunHtmlBuildMessage.php | 15 +++ 5 files changed, 272 insertions(+) create mode 100644 migrations/Version20260218143103.php create mode 100644 src/ChatBasedContentEditor/Domain/Entity/HtmlEditorBuild.php create mode 100644 src/ChatBasedContentEditor/Domain/Enum/HtmlEditorBuildStatus.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/Handler/RunHtmlBuildHandler.php create mode 100644 src/ChatBasedContentEditor/Infrastructure/Message/RunHtmlBuildMessage.php diff --git a/migrations/Version20260218143103.php b/migrations/Version20260218143103.php new file mode 100644 index 00000000..42a36729 --- /dev/null +++ b/migrations/Version20260218143103.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE html_editor_builds (id CHAR(36) NOT NULL, workspace_id CHAR(36) NOT NULL, source_path VARCHAR(512) NOT NULL, user_email VARCHAR(255) NOT NULL, status VARCHAR(32) NOT NULL, error_message LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE html_editor_builds'); + } +} diff --git a/src/ChatBasedContentEditor/Domain/Entity/HtmlEditorBuild.php b/src/ChatBasedContentEditor/Domain/Entity/HtmlEditorBuild.php new file mode 100644 index 00000000..4e31a013 --- /dev/null +++ b/src/ChatBasedContentEditor/Domain/Entity/HtmlEditorBuild.php @@ -0,0 +1,127 @@ +workspaceId = $workspaceId; + $this->sourcePath = $sourcePath; + $this->userEmail = $userEmail; + $this->status = HtmlEditorBuildStatus::Pending; + $this->createdAt = DateAndTimeService::getDateTimeImmutable(); + } + + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: UuidGenerator::class)] + #[ORM\Column( + type: Types::GUID, + unique: true + )] + private ?string $id = null; + + public function getId(): ?string + { + return $this->id; + } + + #[ORM\Column( + type: Types::GUID, + nullable: false + )] + private readonly string $workspaceId; + + public function getWorkspaceId(): string + { + return $this->workspaceId; + } + + #[ORM\Column( + type: Types::STRING, + length: 512, + nullable: false + )] + private readonly string $sourcePath; + + public function getSourcePath(): string + { + return $this->sourcePath; + } + + #[ORM\Column( + type: Types::STRING, + length: 255, + nullable: false + )] + private readonly string $userEmail; + + public function getUserEmail(): string + { + return $this->userEmail; + } + + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: HtmlEditorBuildStatus::class + )] + private HtmlEditorBuildStatus $status; + + public function getStatus(): HtmlEditorBuildStatus + { + return $this->status; + } + + public function setStatus(HtmlEditorBuildStatus $status): void + { + $this->status = $status; + } + + #[ORM\Column( + type: Types::TEXT, + nullable: true + )] + private ?string $errorMessage = null; + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function setErrorMessage(?string $errorMessage): void + { + $this->errorMessage = $errorMessage; + } + + #[ORM\Column( + type: Types::DATETIME_IMMUTABLE, + nullable: false + )] + private readonly DateTimeImmutable $createdAt; + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/ChatBasedContentEditor/Domain/Enum/HtmlEditorBuildStatus.php b/src/ChatBasedContentEditor/Domain/Enum/HtmlEditorBuildStatus.php new file mode 100644 index 00000000..843d05c5 --- /dev/null +++ b/src/ChatBasedContentEditor/Domain/Enum/HtmlEditorBuildStatus.php @@ -0,0 +1,13 @@ +entityManager->find(HtmlEditorBuild::class, $message->buildId); + + if ($build === null) { + $this->logger->error('HtmlEditorBuild not found', ['buildId' => $message->buildId]); + + return; + } + + $build->setStatus(HtmlEditorBuildStatus::Running); + $this->entityManager->flush(); + + try { + $workspace = $this->workspaceMgmtFacade->getWorkspaceById($build->getWorkspaceId()); + + if ($workspace === null) { + throw new RuntimeException('Workspace not found: ' . $build->getWorkspaceId()); + } + + $project = $this->projectMgmtFacade->getProjectInfo($workspace->projectId); + + $buildOutput = $this->workspaceToolingService->runBuildInWorkspace( + $workspace->workspacePath, + $project->agentImage + ); + + $this->logger->info('HTML editor Docker build completed', [ + 'buildId' => $message->buildId, + 'workspaceId' => $build->getWorkspaceId(), + 'sourcePath' => $build->getSourcePath(), + 'buildOutput' => $buildOutput, + ]); + + $this->workspaceMgmtFacade->commitAndPush( + $build->getWorkspaceId(), + 'Manual HTML edit: ' . $build->getSourcePath(), + $build->getUserEmail() + ); + + $build->setStatus(HtmlEditorBuildStatus::Completed); + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error('HTML editor build failed', [ + 'buildId' => $message->buildId, + 'workspaceId' => $build->getWorkspaceId(), + 'sourcePath' => $build->getSourcePath(), + 'error' => $e->getMessage(), + ]); + + $build->setStatus(HtmlEditorBuildStatus::Failed); + $build->setErrorMessage($e->getMessage()); + $this->entityManager->flush(); + } + } +} diff --git a/src/ChatBasedContentEditor/Infrastructure/Message/RunHtmlBuildMessage.php b/src/ChatBasedContentEditor/Infrastructure/Message/RunHtmlBuildMessage.php new file mode 100644 index 00000000..f994bbb1 --- /dev/null +++ b/src/ChatBasedContentEditor/Infrastructure/Message/RunHtmlBuildMessage.php @@ -0,0 +1,15 @@ +