From 2eed58f37c0a658e75da0cbff9b43a72f48c712a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Mon, 4 May 2026 19:43:10 +0100 Subject: [PATCH] fix(docker): show task titles in container lists --- apps/backend/cmd/kandev/helpers.go | 17 ++++++- .../backend/internal/agent/docker/handlers.go | 36 ++++++++++++--- .../internal/agent/docker/handlers_test.go | 25 +++++++++++ .../internal/agent/lifecycle/container.go | 4 ++ .../agent/lifecycle/container_config_test.go | 2 + .../agent/lifecycle/executor_backend.go | 1 + .../agent/lifecycle/executor_docker.go | 1 + .../agent/lifecycle/manager_launch.go | 1 + .../profile-edit/docker-sections.test.ts | 44 +++++++++++++++++++ .../settings/profile-edit/docker-sections.tsx | 7 +-- .../docker-profile-persistence.spec.ts | 3 +- 11 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 apps/web/components/settings/profile-edit/docker-sections.test.ts diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index d00c074c66..4ab3acb05c 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -626,7 +626,7 @@ func registerSecondaryRoutes( p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") } - docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), dockerTaskTitleProvider(p.taskRepo, p.log), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") registerHealthRoutes(p) @@ -645,6 +645,21 @@ func registerSecondaryRoutes( registerE2EResetRoutes(p.router, p.taskRepo, p.log) } +func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider { + return func(ctx context.Context, taskID string) (string, bool) { + if taskRepo == nil || taskID == "" { + return "", false + } + task, err := taskRepo.GetTask(ctx, taskID) + if err != nil { + log.Debug("docker container task title lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + return "", false + } + return task.Title, task.Title != "" + } +} + // registerHealthRoutes sets up the system health endpoint with all health checkers. func registerHealthRoutes(p routeParams) { var githubProvider health.GitHubStatusProvider diff --git a/apps/backend/internal/agent/docker/handlers.go b/apps/backend/internal/agent/docker/handlers.go index aa6502f67c..a169334333 100644 --- a/apps/backend/internal/agent/docker/handlers.go +++ b/apps/backend/internal/agent/docker/handlers.go @@ -3,6 +3,7 @@ package docker import ( "bufio" + "context" "net/http" "time" @@ -37,12 +38,15 @@ type stopContainerRequest struct { // ClientProvider lazily resolves the Docker client. Returns nil if Docker is unavailable. type ClientProvider func() *Client +// TaskTitleProvider resolves a task ID to its display title for container listings. +type TaskTitleProvider func(ctx context.Context, taskID string) (string, bool) + // RegisterDockerRoutes registers Docker management HTTP routes on the given router. // The clientProvider lazily resolves the Docker client on each request. -func RegisterDockerRoutes(router *gin.Engine, clientProvider ClientProvider, log *logger.Logger) { +func RegisterDockerRoutes(router *gin.Engine, clientProvider ClientProvider, taskTitleProvider TaskTitleProvider, log *logger.Logger) { api := router.Group("/api/v1/docker") api.POST("/build", handleBuildImage(clientProvider, log)) - api.GET("/containers", handleListContainers(clientProvider, log)) + api.GET("/containers", handleListContainers(clientProvider, taskTitleProvider, log)) api.POST("/containers/:id/stop", handleStopContainer(clientProvider, log)) api.DELETE("/containers/:id", handleRemoveContainer(clientProvider, log)) } @@ -114,7 +118,7 @@ func streamBuildOutput(c *gin.Context, reader interface{ Read([]byte) (int, erro // handleListContainers handles GET /api/v1/docker/containers. // Supports optional query params: image, labels (comma-separated key=value pairs). -func handleListContainers(clientProvider ClientProvider, log *logger.Logger) gin.HandlerFunc { +func handleListContainers(clientProvider ClientProvider, taskTitleProvider TaskTitleProvider, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { dockerClient := requireDocker(c, clientProvider) if dockerClient == nil { @@ -131,13 +135,17 @@ func handleListContainers(clientProvider ClientProvider, log *logger.Logger) gin return } - resp := newContainerResponses(containers) + resp := newContainerResponsesWithTaskTitles(c.Request.Context(), containers, taskTitleProvider) c.JSON(http.StatusOK, gin.H{"containers": resp}) } } func newContainerResponses(containers []ContainerInfo) []containerResponse { + return newContainerResponsesWithTaskTitles(context.Background(), containers, nil) +} + +func newContainerResponsesWithTaskTitles(ctx context.Context, containers []ContainerInfo, taskTitleProvider TaskTitleProvider) []containerResponse { resp := make([]containerResponse, len(containers)) for i, ctr := range containers { resp[i] = containerResponse{ @@ -147,12 +155,30 @@ func newContainerResponses(containers []ContainerInfo) []containerResponse { State: ctr.State, Status: ctr.Status, StartedAt: ctr.StartedAt, - Labels: ctr.Labels, + Labels: labelsWithTaskTitle(ctx, ctr.Labels, taskTitleProvider), } } return resp } +func labelsWithTaskTitle(ctx context.Context, labels map[string]string, taskTitleProvider TaskTitleProvider) map[string]string { + if len(labels) == 0 { + return labels + } + result := make(map[string]string, len(labels)+1) + for key, value := range labels { + result[key] = value + } + if result["kandev.task_title"] != "" || taskTitleProvider == nil { + return result + } + title, ok := taskTitleProvider(ctx, result["kandev.task_id"]) + if ok && title != "" { + result["kandev.task_title"] = title + } + return result +} + // parseLabelsQuery extracts label filters from the "labels" query parameter. // Expected format: "key1=value1,key2=value2". func parseLabelsQuery(c *gin.Context) map[string]string { diff --git a/apps/backend/internal/agent/docker/handlers_test.go b/apps/backend/internal/agent/docker/handlers_test.go index 7d1e181e4c..48f0d0d121 100644 --- a/apps/backend/internal/agent/docker/handlers_test.go +++ b/apps/backend/internal/agent/docker/handlers_test.go @@ -1,6 +1,7 @@ package docker import ( + "context" "testing" "time" ) @@ -28,3 +29,27 @@ func TestContainerResponsesIncludeLabels(t *testing.T) { t.Fatalf("labels = %#v, want task label", got[0].Labels) } } + +func TestContainerResponsesEnrichTaskTitleLabel(t *testing.T) { + containers := []ContainerInfo{{ + ID: "container-1", + Name: "kandev-agent-1", + Image: "kandev/agent:test", + State: "running", + Status: "Up 2 seconds", + Labels: map[string]string{ + "kandev.task_id": "task-1", + }, + }} + + got := newContainerResponsesWithTaskTitles(context.Background(), containers, func(_ context.Context, taskID string) (string, bool) { + return "Readable Task Title", taskID == "task-1" + }) + + if got[0].Labels["kandev.task_title"] != "Readable Task Title" { + t.Fatalf("labels = %#v, want task title label", got[0].Labels) + } + if _, exists := containers[0].Labels["kandev.task_title"]; exists { + t.Fatalf("source labels mutated: %#v", containers[0].Labels) + } +} diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index 18ffe5b91f..4887fc1f97 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -30,6 +30,7 @@ type ContainerConfig struct { AgentConfig agents.Agent WorkspacePath string // If empty, workspace is not mounted (will clone inside container) TaskID string + TaskTitle string TaskEnvironmentID string TaskDescription string Model string @@ -447,6 +448,9 @@ exec /usr/local/bin/agentctl`, containerCfg.Labels["kandev.executor_profile_id"] = config.ExecutorProfileID containerCfg.Labels["kandev.profile_id"] = config.ExecutorProfileID } + if config.TaskTitle != "" { + containerCfg.Labels["kandev.task_title"] = config.TaskTitle + } if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" { containerCfg.Labels["kandev.profile_id"] = config.ProfileInfo.ProfileID } diff --git a/apps/backend/internal/agent/lifecycle/container_config_test.go b/apps/backend/internal/agent/lifecycle/container_config_test.go index 3e0d33b7ff..770f3da878 100644 --- a/apps/backend/internal/agent/lifecycle/container_config_test.go +++ b/apps/backend/internal/agent/lifecycle/container_config_test.go @@ -134,6 +134,7 @@ func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing AgentConfig: newConfigStubAgent(), InstanceID: "0123456789abcdef", TaskID: "task-1", + TaskTitle: "Readable Task Title", SessionID: "session-1", TaskEnvironmentID: "env-1", ExecutorProfileID: "profile-1", @@ -147,6 +148,7 @@ func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing assertLabel(t, got.Labels, "kandev.managed", boolStringTrue) assertLabel(t, got.Labels, "kandev.task_id", "task-1") + assertLabel(t, got.Labels, "kandev.task_title", "Readable Task Title") assertLabel(t, got.Labels, "kandev.session_id", "session-1") assertLabel(t, got.Labels, "kandev.task_environment_id", "env-1") assertLabel(t, got.Labels, "kandev.executor_profile_id", "profile-1") diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index 08ed76641a..b437140def 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -173,6 +173,7 @@ type RemoteStatusProvider interface { type ExecutorCreateRequest struct { InstanceID string TaskID string + TaskTitle string SessionID string TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task) AgentProfileID string diff --git a/apps/backend/internal/agent/lifecycle/executor_docker.go b/apps/backend/internal/agent/lifecycle/executor_docker.go index 4f909adf20..70f32e1666 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker.go @@ -165,6 +165,7 @@ func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreate AgentConfig: req.AgentConfig, WorkspacePath: "", // Empty = no workspace mount, we'll clone instead TaskID: req.TaskID, + TaskTitle: req.TaskTitle, TaskEnvironmentID: req.TaskEnvironmentID, SessionID: req.SessionID, ExecutorProfileID: getMetadataString(req.Metadata, "executor_profile_id"), diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 057a218b09..de780fd788 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -365,6 +365,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st execReq := &ExecutorCreateRequest{ InstanceID: executionID, TaskID: reqWithWorktree.TaskID, + TaskTitle: reqWithWorktree.TaskTitle, SessionID: reqWithWorktree.SessionID, TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID, AgentProfileID: reqWithWorktree.AgentProfileID, diff --git a/apps/web/components/settings/profile-edit/docker-sections.test.ts b/apps/web/components/settings/profile-edit/docker-sections.test.ts new file mode 100644 index 0000000000..e5d0094390 --- /dev/null +++ b/apps/web/components/settings/profile-edit/docker-sections.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { containerTaskLabel } from "./docker-sections"; +import type { DockerContainer } from "@/lib/api/domains/settings-api"; + +const taskID = "task-abc123"; + +function container(labels?: Record): DockerContainer { + return { + id: "container-1", + name: "kandev-agent-test", + image: "kandev/multi-agent:latest", + state: "running", + status: "Up 5 seconds", + labels, + }; +} + +describe("containerTaskLabel", () => { + it("prefers task title over task id", () => { + expect( + containerTaskLabel( + container({ + "kandev.task_id": taskID, + "kandev.task_title": "Fix Docker reuse", + }), + ), + ).toBe("Fix Docker reuse"); + }); + + it("falls back to task id for older containers", () => { + expect(containerTaskLabel(container({ "kandev.task_id": taskID }))).toBe(taskID); + }); + + it("falls back to task id when the task title label is empty", () => { + expect( + containerTaskLabel( + container({ + "kandev.task_id": taskID, + "kandev.task_title": " ", + }), + ), + ).toBe(taskID); + }); +}); diff --git a/apps/web/components/settings/profile-edit/docker-sections.tsx b/apps/web/components/settings/profile-edit/docker-sections.tsx index 0456572912..18eb023ef3 100644 --- a/apps/web/components/settings/profile-edit/docker-sections.tsx +++ b/apps/web/components/settings/profile-edit/docker-sections.tsx @@ -293,8 +293,9 @@ export function useDockerProfileContainers(profileId: string, enabled = true) { return { containers, loading, refresh }; } -function containerTaskLabel(container: DockerContainer) { - return container.labels?.["kandev.task_id"] ?? "Untracked"; +export function containerTaskLabel(container: DockerContainer) { + const title = container.labels?.["kandev.task_title"]?.trim(); + return title || container.labels?.["kandev.task_id"] || "Untracked"; } function ContainerRow({ @@ -313,7 +314,7 @@ function ContainerRow({ {container.name} {container.image} - + {containerTaskLabel(container)} diff --git a/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts b/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts index a8e0b4edfd..97b970b7cf 100644 --- a/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts +++ b/apps/web/e2e/tests/settings/docker-profile-persistence.spec.ts @@ -84,6 +84,7 @@ test.describe("Docker executor profile persistence", () => { labels: { "kandev.executor_profile_id": profile.id, "kandev.task_id": "task-abc123", + "kandev.task_title": "Readable Task Title", "kandev.task_environment_id": "env-123", }, }, @@ -117,7 +118,7 @@ test.describe("Docker executor profile persistence", () => { const row = testPage.getByTestId("docker-container-row-container-1"); await expect(row).toBeVisible({ timeout: 10_000 }); - await expect(row.getByTestId("docker-container-task")).toContainText("task-abc123"); + await expect(row.getByTestId("docker-container-task")).toContainText("Readable Task Title"); await expect(row.getByText("Up 5 seconds")).toBeVisible(); await testPage.getByRole("button", { name: "Delete Profile" }).click();