From a5b86263458b43261ae023cf1eaeaf2ea92f7ae4 Mon Sep 17 00:00:00 2001 From: Thai Nguyen Date: Tue, 18 Aug 2026 17:24:15 +0700 Subject: [PATCH 01/16] fix: preserve selected stack log container --- .../dashboard/compose/logs/show-stack.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index a7b4d3014..8b46b0666 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -72,16 +72,19 @@ export const ShowDockerLogsStack = ({ const containers = data?.filter((container) => container.containerId); useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } + const currentContainers = option === "native" ? containers : services; + + if ( + currentContainers && + currentContainers.length > 0 && + (!containerId || + !currentContainers.some( + (container) => container.containerId === containerId, + )) + ) { + setContainerId(currentContainers[0]?.containerId); } - }, [option, services, containers]); + }, [option, services, containers, containerId]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = From c77f6d889d088fff01790db2d59c107c1abdb4e5 Mon Sep 17 00:00:00 2001 From: Thai Nguyen Date: Tue, 18 Aug 2026 17:34:18 +0700 Subject: [PATCH 02/16] fix: clear stale stack log container selection --- .../dashboard/compose/logs/show-stack.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 8b46b0666..68b0868fc 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -74,15 +74,16 @@ export const ShowDockerLogsStack = ({ useEffect(() => { const currentContainers = option === "native" ? containers : services; - if ( - currentContainers && - currentContainers.length > 0 && - (!containerId || - !currentContainers.some( - (container) => container.containerId === containerId, - )) - ) { - setContainerId(currentContainers[0]?.containerId); + if (currentContainers) { + const nextContainerId = currentContainers.some( + (container) => container.containerId === containerId, + ) + ? containerId + : currentContainers[0]?.containerId; + + if (nextContainerId !== containerId) { + setContainerId(nextContainerId); + } } }, [option, services, containers, containerId]); From 2221b8a99cb70d85a7a7527540f64151ca0c0bc9 Mon Sep 17 00:00:00 2001 From: Thai Nguyen Date: Tue, 18 Aug 2026 17:47:59 +0700 Subject: [PATCH 03/16] fix: preserve stack log selection on fetch failures --- .../services/docker-stack-containers.test.ts | 69 +++++++++++++++++++ packages/server/src/services/docker.ts | 10 +-- 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/__test__/services/docker-stack-containers.test.ts diff --git a/apps/dokploy/__test__/services/docker-stack-containers.test.ts b/apps/dokploy/__test__/services/docker-stack-containers.test.ts new file mode 100644 index 000000000..b8d06b4f9 --- /dev/null +++ b/apps/dokploy/__test__/services/docker-stack-containers.test.ts @@ -0,0 +1,69 @@ +import { + getContainersByAppNameMatch, + getStackContainersByAppName, +} from "@dokploy/server/services/docker"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +const execAsyncMock = vi.mocked(execAsync); +const execAsyncRemoteMock = vi.mocked(execAsyncRemote); + +describe("stack container lookup results", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("returns undefined when the native stack lookup fails", async () => { + execAsyncMock.mockRejectedValueOnce(new Error("Docker is unavailable")); + + await expect( + getContainersByAppNameMatch("farmgate-odoo", "stack"), + ).resolves.toBeUndefined(); + }); + + it("returns an empty list for a successful native lookup with no tasks", async () => { + execAsyncMock.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await expect( + getContainersByAppNameMatch("farmgate-odoo", "stack"), + ).resolves.toEqual([]); + }); + + it("returns undefined when the remote native stack lookup fails", async () => { + execAsyncRemoteMock.mockRejectedValueOnce(new Error("SSH is unavailable")); + + await expect( + getContainersByAppNameMatch("farmgate-odoo", "stack", "server-1"), + ).resolves.toBeUndefined(); + }); + + it("returns undefined when the Swarm stack lookup reports an error", async () => { + execAsyncRemoteMock.mockResolvedValueOnce({ + stdout: "", + stderr: "node is unavailable", + }); + + await expect( + getStackContainersByAppName("farmgate-odoo", "server-1"), + ).resolves.toBeUndefined(); + }); + + it("returns an empty list for a successful Swarm lookup with no tasks", async () => { + execAsyncMock.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await expect(getStackContainersByAppName("farmgate-odoo")).resolves.toEqual( + [], + ); + }); +}); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 65eb29041..72d81fc3d 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -167,7 +167,7 @@ export const getContainersByAppNameMatch = async ( return containers || []; } catch {} - return []; + return appType === "stack" ? undefined : []; }; const getStackTaskContainers = async (appName: string, serverId?: string) => { @@ -227,7 +227,7 @@ const getStackTaskContainers = async (appName: string, serverId?: string) => { return containers; } catch {} - return []; + return undefined; }; export const getStackContainersByAppName = async ( @@ -243,7 +243,7 @@ export const getStackContainersByAppName = async ( const { stdout, stderr } = await execAsyncRemote(serverId, command); if (stderr) { - return []; + return undefined; } if (!stdout) return []; @@ -252,7 +252,7 @@ export const getStackContainersByAppName = async ( const { stdout, stderr } = await execAsync(command); if (stderr) { - return []; + return undefined; } if (!stdout) return []; @@ -292,7 +292,7 @@ export const getStackContainersByAppName = async ( return containers || []; } catch {} - return []; + return undefined; }; export const getServiceContainersByAppName = async ( From 49055a05b17bbe26543cf18a2e958bc14b2b03ad Mon Sep 17 00:00:00 2001 From: Thai Nguyen Date: Tue, 18 Aug 2026 17:52:46 +0700 Subject: [PATCH 04/16] fix: surface native stack inspection failures --- .../dokploy/__test__/services/docker-stack-containers.test.ts | 4 ++++ packages/server/src/services/docker.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/services/docker-stack-containers.test.ts b/apps/dokploy/__test__/services/docker-stack-containers.test.ts index b8d06b4f9..c4de40613 100644 --- a/apps/dokploy/__test__/services/docker-stack-containers.test.ts +++ b/apps/dokploy/__test__/services/docker-stack-containers.test.ts @@ -30,6 +30,10 @@ describe("stack container lookup results", () => { await expect( getContainersByAppNameMatch("farmgate-odoo", "stack"), ).resolves.toBeUndefined(); + + const command = execAsyncMock.mock.calls[0]?.[0]; + expect(command).toBeDefined(); + expect(command).not.toContain("|| true"); }); it("returns an empty list for a successful native lookup with no tasks", async () => { diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 72d81fc3d..cda350e63 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -175,7 +175,7 @@ const getStackTaskContainers = async (appName: string, serverId?: string) => { const divider = "__DOKPLOY_DIVIDER__"; const tasksCommand = `docker stack ps ${appName} --no-trunc --filter "desired-state=running" --format 'TASK : {{.ID}} | Name: {{.Name}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`; const inspectCommand = `docker stack ps ${appName} -q --no-trunc --filter "desired-state=running" | xargs -r docker inspect --format '{{if .Status.ContainerStatus}}TASK : {{.ID}} | ContainerId: {{.Status.ContainerStatus.ContainerID}}{{end}}' 2>/dev/null`; - const command = `${tasksCommand} && echo "${divider}" && (${inspectCommand} || true)`; + const command = `${tasksCommand} && echo "${divider}" && ${inspectCommand}`; let stdout = ""; From 60612b098602688b726eb6f6b914a2920a3b648f Mon Sep 17 00:00:00 2001 From: Thai Nguyen Date: Tue, 18 Aug 2026 22:10:54 +0700 Subject: [PATCH 05/16] fix: reset stack log selection when switching modes --- apps/dokploy/components/dashboard/compose/logs/show-stack.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 68b0868fc..558dc1c0f 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -110,6 +110,7 @@ export const ShowDockerLogsStack = ({ { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> From 0be822ef5b1b074d42e16e3f3ee2bdd8fd0e44d5 Mon Sep 17 00:00:00 2001 From: Thai Nguyen Date: Wed, 19 Aug 2026 10:33:49 +0700 Subject: [PATCH 06/16] refactor: keep stack log fix focused --- .../services/docker-stack-containers.test.ts | 73 ------------------- packages/server/src/services/docker.ts | 12 +-- 2 files changed, 6 insertions(+), 79 deletions(-) delete mode 100644 apps/dokploy/__test__/services/docker-stack-containers.test.ts diff --git a/apps/dokploy/__test__/services/docker-stack-containers.test.ts b/apps/dokploy/__test__/services/docker-stack-containers.test.ts deleted file mode 100644 index c4de40613..000000000 --- a/apps/dokploy/__test__/services/docker-stack-containers.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - getContainersByAppNameMatch, - getStackContainersByAppName, -} from "@dokploy/server/services/docker"; -import { - execAsync, - execAsyncRemote, -} from "@dokploy/server/utils/process/execAsync"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ - ...(await importOriginal< - typeof import("@dokploy/server/utils/process/execAsync") - >()), - execAsync: vi.fn(), - execAsyncRemote: vi.fn(), -})); - -const execAsyncMock = vi.mocked(execAsync); -const execAsyncRemoteMock = vi.mocked(execAsyncRemote); - -describe("stack container lookup results", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it("returns undefined when the native stack lookup fails", async () => { - execAsyncMock.mockRejectedValueOnce(new Error("Docker is unavailable")); - - await expect( - getContainersByAppNameMatch("farmgate-odoo", "stack"), - ).resolves.toBeUndefined(); - - const command = execAsyncMock.mock.calls[0]?.[0]; - expect(command).toBeDefined(); - expect(command).not.toContain("|| true"); - }); - - it("returns an empty list for a successful native lookup with no tasks", async () => { - execAsyncMock.mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await expect( - getContainersByAppNameMatch("farmgate-odoo", "stack"), - ).resolves.toEqual([]); - }); - - it("returns undefined when the remote native stack lookup fails", async () => { - execAsyncRemoteMock.mockRejectedValueOnce(new Error("SSH is unavailable")); - - await expect( - getContainersByAppNameMatch("farmgate-odoo", "stack", "server-1"), - ).resolves.toBeUndefined(); - }); - - it("returns undefined when the Swarm stack lookup reports an error", async () => { - execAsyncRemoteMock.mockResolvedValueOnce({ - stdout: "", - stderr: "node is unavailable", - }); - - await expect( - getStackContainersByAppName("farmgate-odoo", "server-1"), - ).resolves.toBeUndefined(); - }); - - it("returns an empty list for a successful Swarm lookup with no tasks", async () => { - execAsyncMock.mockResolvedValueOnce({ stdout: "", stderr: "" }); - - await expect(getStackContainersByAppName("farmgate-odoo")).resolves.toEqual( - [], - ); - }); -}); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index cda350e63..65eb29041 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -167,7 +167,7 @@ export const getContainersByAppNameMatch = async ( return containers || []; } catch {} - return appType === "stack" ? undefined : []; + return []; }; const getStackTaskContainers = async (appName: string, serverId?: string) => { @@ -175,7 +175,7 @@ const getStackTaskContainers = async (appName: string, serverId?: string) => { const divider = "__DOKPLOY_DIVIDER__"; const tasksCommand = `docker stack ps ${appName} --no-trunc --filter "desired-state=running" --format 'TASK : {{.ID}} | Name: {{.Name}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`; const inspectCommand = `docker stack ps ${appName} -q --no-trunc --filter "desired-state=running" | xargs -r docker inspect --format '{{if .Status.ContainerStatus}}TASK : {{.ID}} | ContainerId: {{.Status.ContainerStatus.ContainerID}}{{end}}' 2>/dev/null`; - const command = `${tasksCommand} && echo "${divider}" && ${inspectCommand}`; + const command = `${tasksCommand} && echo "${divider}" && (${inspectCommand} || true)`; let stdout = ""; @@ -227,7 +227,7 @@ const getStackTaskContainers = async (appName: string, serverId?: string) => { return containers; } catch {} - return undefined; + return []; }; export const getStackContainersByAppName = async ( @@ -243,7 +243,7 @@ export const getStackContainersByAppName = async ( const { stdout, stderr } = await execAsyncRemote(serverId, command); if (stderr) { - return undefined; + return []; } if (!stdout) return []; @@ -252,7 +252,7 @@ export const getStackContainersByAppName = async ( const { stdout, stderr } = await execAsync(command); if (stderr) { - return undefined; + return []; } if (!stdout) return []; @@ -292,7 +292,7 @@ export const getStackContainersByAppName = async ( return containers || []; } catch {} - return undefined; + return []; }; export const getServiceContainersByAppName = async ( From b4edb56132f7443a41beb1fe857bf881ab0c077d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Wed, 19 Aug 2026 16:00:26 -0400 Subject: [PATCH 07/16] Merge pull request #5116 from thaingnguyen/agent/fix-stack-log-container-selection fix: preserve selected stack log container (cherry picked from commit 9dab15ad4761e9970f782a61dde1cfa70ded6cea) [skip ci] --- .../dashboard/compose/logs/show-stack.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index a7b4d3014..558dc1c0f 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -72,16 +72,20 @@ export const ShowDockerLogsStack = ({ const containers = data?.filter((container) => container.containerId); useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); + const currentContainers = option === "native" ? containers : services; + + if (currentContainers) { + const nextContainerId = currentContainers.some( + (container) => container.containerId === containerId, + ) + ? containerId + : currentContainers[0]?.containerId; + + if (nextContainerId !== containerId) { + setContainerId(nextContainerId); } } - }, [option, services, containers]); + }, [option, services, containers, containerId]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -106,6 +110,7 @@ export const ShowDockerLogsStack = ({ { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> From d7c152af0dd12c3a0cc5268066ecffe0feb2f7dc Mon Sep 17 00:00:00 2001 From: Aditya Nandlal <73009776+bestmaa@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:03:39 +0000 Subject: [PATCH 08/16] fix: preserve selected log container on refetch --- .../__test__/logs/container-selection.test.ts | 41 +++++++++++++++++++ .../application/logs/container-selection.ts | 21 ++++++++++ .../dashboard/application/logs/show.tsx | 18 ++++---- 3 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 apps/dokploy/__test__/logs/container-selection.test.ts create mode 100644 apps/dokploy/components/dashboard/application/logs/container-selection.ts diff --git a/apps/dokploy/__test__/logs/container-selection.test.ts b/apps/dokploy/__test__/logs/container-selection.test.ts new file mode 100644 index 000000000..e87817556 --- /dev/null +++ b/apps/dokploy/__test__/logs/container-selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveContainerSelection } from "@/components/dashboard/application/logs/container-selection"; + +const containers = [ + { containerId: "first-container" }, + { containerId: "selected-container" }, +]; + +describe("resolveContainerSelection", () => { + it("selects the first container when no container is selected", () => { + expect(resolveContainerSelection(undefined, containers)).toBe( + "first-container", + ); + }); + + it("preserves a manual selection when refreshed data contains it", () => { + const refreshedContainers = containers.map((container) => ({ + ...container, + })); + + expect( + resolveContainerSelection("selected-container", refreshedContainers), + ).toBe("selected-container"); + }); + + it("falls back to the first container when the selection disappears", () => { + expect(resolveContainerSelection("removed-container", containers)).toBe( + "first-container", + ); + }); + + it("keeps the current selection while container data is loading", () => { + expect(resolveContainerSelection("selected-container", undefined)).toBe( + "selected-container", + ); + }); + + it("clears the selection when no containers are available", () => { + expect(resolveContainerSelection("selected-container", [])).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/logs/container-selection.ts b/apps/dokploy/components/dashboard/application/logs/container-selection.ts new file mode 100644 index 000000000..445ffb0a8 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/logs/container-selection.ts @@ -0,0 +1,21 @@ +interface ContainerOption { + containerId: string; +} + +export const resolveContainerSelection = ( + currentContainerId: string | undefined, + containers: readonly ContainerOption[] | undefined, +) => { + if (!containers) { + return currentContainerId; + } + + if ( + currentContainerId && + containers.some(({ containerId }) => containerId === currentContainerId) + ) { + return currentContainerId; + } + + return containers[0]?.containerId; +}; diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 52ab40ec4..d5db191d1 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; +import { resolveContainerSelection } from "./container-selection"; export const DockerLogs = dynamic( () => import("@/components/dashboard/docker/logs/docker-logs-id").then( @@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { }, ); + const availableContainers = option === "native" ? containers : services; + useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } - } - }, [option, services, containers]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> From 62f701425a5e32f54efa9c6ef1d0004386104789 Mon Sep 17 00:00:00 2001 From: Aditya Nandlal <73009776+bestmaa@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:32:49 +0000 Subject: [PATCH 09/16] refactor: share log container selection --- .../__test__/logs/container-selection.test.ts | 2 +- .../application/logs/container-selection.ts | 21 ------------------ .../dashboard/application/logs/show.tsx | 2 +- .../dashboard/compose/logs/show-stack.tsx | 20 +++++------------ .../components/dashboard/docker/logs/utils.ts | 22 +++++++++++++++++++ 5 files changed, 30 insertions(+), 37 deletions(-) delete mode 100644 apps/dokploy/components/dashboard/application/logs/container-selection.ts diff --git a/apps/dokploy/__test__/logs/container-selection.test.ts b/apps/dokploy/__test__/logs/container-selection.test.ts index e87817556..d8af2ede4 100644 --- a/apps/dokploy/__test__/logs/container-selection.test.ts +++ b/apps/dokploy/__test__/logs/container-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveContainerSelection } from "@/components/dashboard/application/logs/container-selection"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; const containers = [ { containerId: "first-container" }, diff --git a/apps/dokploy/components/dashboard/application/logs/container-selection.ts b/apps/dokploy/components/dashboard/application/logs/container-selection.ts deleted file mode 100644 index 445ffb0a8..000000000 --- a/apps/dokploy/components/dashboard/application/logs/container-selection.ts +++ /dev/null @@ -1,21 +0,0 @@ -interface ContainerOption { - containerId: string; -} - -export const resolveContainerSelection = ( - currentContainerId: string | undefined, - containers: readonly ContainerOption[] | undefined, -) => { - if (!containers) { - return currentContainerId; - } - - if ( - currentContainerId && - containers.some(({ containerId }) => containerId === currentContainerId) - ) { - return currentContainerId; - } - - return containers[0]?.containerId; -}; diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index d5db191d1..372640906 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -1,6 +1,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -21,7 +22,6 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; -import { resolveContainerSelection } from "./container-selection"; export const DockerLogs = dynamic( () => import("@/components/dashboard/docker/logs/docker-logs-id").then( diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 558dc1c0f..acf296f7e 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -70,22 +71,13 @@ export const ShowDockerLogsStack = ({ ); const containers = data?.filter((container) => container.containerId); + const availableContainers = option === "native" ? containers : services; useEffect(() => { - const currentContainers = option === "native" ? containers : services; - - if (currentContainers) { - const nextContainerId = currentContainers.some( - (container) => container.containerId === containerId, - ) - ? containerId - : currentContainers[0]?.containerId; - - if (nextContainerId !== containerId) { - setContainerId(nextContainerId); - } - } - }, [option, services, containers, containerId]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index f817d980e..81d86408d 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -7,6 +7,28 @@ export interface LogLine { message: string; } +interface ContainerOption { + containerId: string; +} + +export const resolveContainerSelection = ( + currentContainerId: string | undefined, + containers: readonly ContainerOption[] | undefined, +) => { + if (!containers) { + return currentContainerId; + } + + if ( + currentContainerId && + containers.some(({ containerId }) => containerId === currentContainerId) + ) { + return currentContainerId; + } + + return containers[0]?.containerId; +}; + interface LogStyle { type: LogType; variant: LogVariant; From d3d8d9ec830ec87d8b0d188e46f7b936d3d1d3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Fri, 21 Aug 2026 11:14:37 -0400 Subject: [PATCH 10/16] Merge pull request #5137 from bestmaa/fix/5111-preserve-log-container-selection fix: preserve selected log container on refetch (cherry picked from commit f4dfb6a903cb2b25e0e38fb47dc85a63a0ef2a2d) [skip ci] --- .../__test__/logs/container-selection.test.ts | 41 +++++++++++++++++++ .../dashboard/application/logs/show.tsx | 18 ++++---- .../dashboard/compose/logs/show-stack.tsx | 20 +++------ .../components/dashboard/docker/logs/utils.ts | 22 ++++++++++ 4 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 apps/dokploy/__test__/logs/container-selection.test.ts diff --git a/apps/dokploy/__test__/logs/container-selection.test.ts b/apps/dokploy/__test__/logs/container-selection.test.ts new file mode 100644 index 000000000..d8af2ede4 --- /dev/null +++ b/apps/dokploy/__test__/logs/container-selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; + +const containers = [ + { containerId: "first-container" }, + { containerId: "selected-container" }, +]; + +describe("resolveContainerSelection", () => { + it("selects the first container when no container is selected", () => { + expect(resolveContainerSelection(undefined, containers)).toBe( + "first-container", + ); + }); + + it("preserves a manual selection when refreshed data contains it", () => { + const refreshedContainers = containers.map((container) => ({ + ...container, + })); + + expect( + resolveContainerSelection("selected-container", refreshedContainers), + ).toBe("selected-container"); + }); + + it("falls back to the first container when the selection disappears", () => { + expect(resolveContainerSelection("removed-container", containers)).toBe( + "first-container", + ); + }); + + it("keeps the current selection while container data is loading", () => { + expect(resolveContainerSelection("selected-container", undefined)).toBe( + "selected-container", + ); + }); + + it("clears the selection when no containers are available", () => { + expect(resolveContainerSelection("selected-container", [])).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 52ab40ec4..372640906 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -1,6 +1,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { }, ); + const availableContainers = option === "native" ? containers : services; + useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } - } - }, [option, services, containers]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 558dc1c0f..acf296f7e 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -70,22 +71,13 @@ export const ShowDockerLogsStack = ({ ); const containers = data?.filter((container) => container.containerId); + const availableContainers = option === "native" ? containers : services; useEffect(() => { - const currentContainers = option === "native" ? containers : services; - - if (currentContainers) { - const nextContainerId = currentContainers.some( - (container) => container.containerId === containerId, - ) - ? containerId - : currentContainers[0]?.containerId; - - if (nextContainerId !== containerId) { - setContainerId(nextContainerId); - } - } - }, [option, services, containers, containerId]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index f817d980e..81d86408d 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -7,6 +7,28 @@ export interface LogLine { message: string; } +interface ContainerOption { + containerId: string; +} + +export const resolveContainerSelection = ( + currentContainerId: string | undefined, + containers: readonly ContainerOption[] | undefined, +) => { + if (!containers) { + return currentContainerId; + } + + if ( + currentContainerId && + containers.some(({ containerId }) => containerId === currentContainerId) + ) { + return currentContainerId; + } + + return containers[0]?.containerId; +}; + interface LogStyle { type: LogType; variant: LogVariant; From ede46263965d55ef6453dba4e5e974d1ab2540a0 Mon Sep 17 00:00:00 2001 From: Aditya Nandlal <73009776+bestmaa@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:02:21 +0000 Subject: [PATCH 11/16] fix(traefik): skip empty service reconnect --- .../traefik/reconnect-services.test.ts | 71 +++++++++++++++++++ packages/server/src/services/settings.ts | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/traefik/reconnect-services.test.ts diff --git a/apps/dokploy/__test__/traefik/reconnect-services.test.ts b/apps/dokploy/__test__/traefik/reconnect-services.test.ts new file mode 100644 index 000000000..a60b0504b --- /dev/null +++ b/apps/dokploy/__test__/traefik/reconnect-services.test.ts @@ -0,0 +1,71 @@ +import { reconnectServicesToTraefik } from "@dokploy/server/services/settings"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { + findMany: mocks.findMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +describe("reconnectServicesToTraefik", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findMany.mockResolvedValue([]); + }); + + it("does not execute an empty local command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("does not execute an empty remote command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments to the local Traefik network", async () => { + mocks.findMany.mockResolvedValue([ + { appName: "first-compose" }, + { appName: "second-compose" }, + ]); + + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).toHaveBeenCalledOnce(); + expect(mocks.execAsync).toHaveBeenCalledWith( + 'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' + + 'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments on a remote server", async () => { + mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]); + + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + 'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index ecfb7f6de..d1783dff7 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => { ), }); - if (!composeResult) { + if (composeResult.length === 0) { return; } let commands = ""; From 903789bfe557e32d535fa10aafef292ba68a9ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Narciso=20E=2E=20N=C3=BA=C3=B1ez=20Arias?= Date: Mon, 24 Aug 2026 11:40:38 -0400 Subject: [PATCH 12/16] Merge pull request #5160 from bestmaa/codex/fix-5145-traefik-empty-reconnect fix(traefik): skip empty service reconnect (cherry picked from commit d1273e2fe9d58ba72e55a3c7fe6c6d8b37326e8c) [skip ci] --- .../traefik/reconnect-services.test.ts | 71 +++++++++++++++++++ packages/server/src/services/settings.ts | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/traefik/reconnect-services.test.ts diff --git a/apps/dokploy/__test__/traefik/reconnect-services.test.ts b/apps/dokploy/__test__/traefik/reconnect-services.test.ts new file mode 100644 index 000000000..a60b0504b --- /dev/null +++ b/apps/dokploy/__test__/traefik/reconnect-services.test.ts @@ -0,0 +1,71 @@ +import { reconnectServicesToTraefik } from "@dokploy/server/services/settings"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { + findMany: mocks.findMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +describe("reconnectServicesToTraefik", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findMany.mockResolvedValue([]); + }); + + it("does not execute an empty local command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("does not execute an empty remote command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments to the local Traefik network", async () => { + mocks.findMany.mockResolvedValue([ + { appName: "first-compose" }, + { appName: "second-compose" }, + ]); + + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).toHaveBeenCalledOnce(); + expect(mocks.execAsync).toHaveBeenCalledWith( + 'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' + + 'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments on a remote server", async () => { + mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]); + + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + 'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index ecfb7f6de..d1783dff7 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => { ), }); - if (!composeResult) { + if (composeResult.length === 0) { return; } let commands = ""; From 8be1e68fe1aeb08572b8e49fb908bb4d0dfdd969 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 25 Aug 2026 00:59:44 -0600 Subject: [PATCH 13/16] chore: automate git worktree setup (shared node_modules, per-worktree dev port) --- .claude/settings.json | 19 +++++++++++++++++++ .worktreeinclude | 2 ++ scripts/assign-worktree-port.sh | 22 ++++++++++++++++++++++ scripts/find-free-port.mjs | 23 +++++++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .worktreeinclude create mode 100755 scripts/assign-worktree-port.sh create mode 100644 scripts/find-free-port.mjs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..8955e7721 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "worktree": { + "baseRef": "fresh", + "symlinkDirectories": ["node_modules"] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\"" + } + ] + } + ] + } +} diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 000000000..e3d34d2fb --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,2 @@ +.env +.env.local \ No newline at end of file diff --git a/scripts/assign-worktree-port.sh b/scripts/assign-worktree-port.sh new file mode 100755 index 000000000..0bd544db9 --- /dev/null +++ b/scripts/assign-worktree-port.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +PAYLOAD=$(cat) +WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty') + +if [ -z "$WORKTREE_PATH" ]; then + exit 0 +fi + +ENV_FILE="$WORKTREE_PATH/apps/dokploy/.env" +if [ ! -f "$ENV_FILE" ]; then + exit 0 +fi + +FREE_PORT=$(node "$CLAUDE_PROJECT_DIR/scripts/find-free-port.mjs") + +sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" "$ENV_FILE" +sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" "$ENV_FILE" +rm -f "$ENV_FILE.bak" + +echo "Worktree $WORKTREE_PATH -> dokploy dev PORT=$FREE_PORT" diff --git a/scripts/find-free-port.mjs b/scripts/find-free-port.mjs new file mode 100644 index 000000000..2e647f504 --- /dev/null +++ b/scripts/find-free-port.mjs @@ -0,0 +1,23 @@ +import { createServer } from "node:net"; + +function isFree(port) { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "0.0.0.0", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function findFreePort(start) { + let port = start; + while (!(await isFree(port))) { + port++; + } + return port; +} + +const start = Number.parseInt(process.argv[2] || process.env.PORT || "3000", 10); +const port = await findFreePort(start); +process.stdout.write(String(port)); From 1ccd633153538397f5e31b6fcf995ddac5552802 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 25 Aug 2026 01:02:36 -0600 Subject: [PATCH 14/16] chore: add SKILL.md for issue reproduction and verification process chore: create .mcp.json for MCP server configuration --- .claude/skills/fix-issue/SKILL.md | 39 +++++++++++++++++++++++++++++++ .mcp.json | 13 +++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .claude/skills/fix-issue/SKILL.md create mode 100644 .mcp.json diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md new file mode 100644 index 000000000..524c490ee --- /dev/null +++ b/.claude/skills/fix-issue/SKILL.md @@ -0,0 +1,39 @@ +--- +name: fix-issue +description: Implement a GitHub issue with reproduction and verification +allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__* +--- + +The issue number is passed as $1. An isolated instance is running at $DOKPLOY_BASE_URL. + +## Tools + +- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up + state (create a project, an app, an env var) and to verify backend behavior. + Search for the tool you need; they are not all loaded upfront. +- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything + a user would see or click. + +Pick by where the bug lives, not by convenience: + +- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright. + The API returning correct data proves nothing here. +- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP. + A green screenshot proves nothing here. +- Unclear → do both. + +Use the MCP to reach the state you need quickly, then verify in the UI. Do not +click through ten screens to create a project the API can create in one call. + +## Steps + +1. Run `gh issue view $1` and read the full issue, including comments. +2. Reproduce the bug with the appropriate tool. If you cannot reproduce it, + comment on the issue explaining what you tried and STOP. + Do not implement anything. +3. Implement the fix. Keep the change minimal and scoped to the issue. +4. Run `pnpm test`, then re-run the same reproduction from step 2. +5. Only if both pass: commit and run `gh pr create`. The PR description must + include the before/after reproduction steps and reference the issue. + +Never skip step 2. A fix you cannot reproduce and then verify is not a fix. \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..c195f5d73 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "dokploy": { + "type": "http", + "url": "${DOKPLOY_BASE_URL}/api/mcp", + "headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" } + }, + "playwright": { + "command": "npx", + "args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] + } + } +} From cda047feb29e0359cca6cb97ce87d4d70ce54904 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 25 Aug 2026 01:20:10 -0600 Subject: [PATCH 15/16] chore: add install-worktree-deps.sh script for dependency installation --- .claude/settings.json | 7 +++++-- scripts/install-worktree-deps.sh | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100755 scripts/install-worktree-deps.sh diff --git a/.claude/settings.json b/.claude/settings.json index 8955e7721..77f39858f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,13 +1,16 @@ { "worktree": { - "baseRef": "fresh", - "symlinkDirectories": ["node_modules"] + "baseRef": "fresh" }, "hooks": { "PostToolUse": [ { "matcher": "EnterWorktree", "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\"" + }, { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\"" diff --git a/scripts/install-worktree-deps.sh b/scripts/install-worktree-deps.sh new file mode 100755 index 000000000..2d41f7458 --- /dev/null +++ b/scripts/install-worktree-deps.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +PAYLOAD=$(cat) +WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty') + +if [ -z "$WORKTREE_PATH" ]; then + exit 0 +fi + +# Turbopack refuses to resolve through anything outside its detected +# workspace root, so a symlinked node_modules (whole dir or per-entry) +# doesn't work for apps/dokploy. A real `pnpm install` is required, but +# since pnpm's global content-addressable store is already warm, this +# only links locally — no network fetch, a few seconds. +cd "$WORKTREE_PATH" +pnpm install --prefer-offline From 72a554de5fe5938d53dba6277a3a891bf816fe4f Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 25 Aug 2026 01:29:51 -0600 Subject: [PATCH 16/16] chore: add script to spawn agent worktree with environment setup --- .claude/skills/fix-issue/SKILL.md | 18 +++++++++- scripts/spawn-agent-worktree.sh | 55 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100755 scripts/spawn-agent-worktree.sh diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md index 524c490ee..226083304 100644 --- a/.claude/skills/fix-issue/SKILL.md +++ b/.claude/skills/fix-issue/SKILL.md @@ -4,7 +4,23 @@ description: Implement a GitHub issue with reproduction and verification allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__* --- -The issue number is passed as $1. An isolated instance is running at $DOKPLOY_BASE_URL. +The issue number is passed as $1. + +## Instance + +No instance is running yet — start your own, isolated to this worktree: + +1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already). +2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the + background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT` + until it answers (usually ~10-15s). +3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. + +Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port +discovered mid-session. If those tools are unavailable or point at the wrong +instance, fall back to `curl`/`gh api` for API-level checks, or ask the user +to relaunch with `DOKPLOY_BASE_URL` exported first. ## Tools diff --git a/scripts/spawn-agent-worktree.sh b/scripts/spawn-agent-worktree.sh new file mode 100755 index 000000000..63a6b70a1 --- /dev/null +++ b/scripts/spawn-agent-worktree.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAME="${1:?usage: spawn-agent-worktree.sh }" +# --show-toplevel would return the CURRENT worktree's own root if this is +# run from inside one (e.g. another agent's worktree) instead of the main +# checkout. --git-common-dir always points at the shared .git regardless of +# which worktree you're standing in. +REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")" +WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME" +BRANCH="worktree-$NAME" + +if [ -e "$WORKTREE_PATH" ]; then + echo "Worktree already exists: $WORKTREE_PATH" >&2 + exit 1 +fi + +git -C "$REPO_ROOT" fetch origin canary --quiet +git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2 + +# .worktreeinclude lists gitignored files (.env, .env.local) that a plain +# `git worktree add` won't check out on its own - copy them in. +while IFS= read -r pattern; do + [ -z "$pattern" ] && continue + find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null +done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do + rel="${src#"$REPO_ROOT"/}" + dest="$WORKTREE_PATH/$rel" + mkdir -p "$(dirname "$dest")" + cp "$src" "$dest" +done + +cd "$WORKTREE_PATH" +pnpm install --prefer-offline >&2 + +FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs") +sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env +sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env +rm -f apps/dokploy/.env.bak + +pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 & +echo $! > "$WORKTREE_PATH/dev-server.pid" + +BASE_URL="http://localhost:$FREE_PORT" +for _ in $(seq 1 30); do + CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true) + if [ "$CODE" != "000" ]; then + break + fi + sleep 1 +done + +echo "export WORKTREE_PATH=$WORKTREE_PATH" +echo "export DOKPLOY_BASE_URL=$BASE_URL" +echo "export PORT=$FREE_PORT"