diff --git a/.gitignore b/.gitignore index 2eae89d752..7c441b1dde 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,12 @@ temp/ # production ~/.kandev when developing kandev using kandev. /.kandev-dev/ +# Runtime scratch workspaces created for repo-less tasks. The lifecycle +# manager places them at /tasks//; in dev mode +# (homeDir = .kandev-dev/) they're already covered above, but a misconfigured +# homeDir could land them at the project root. +/tasks/ + # Build artifacts dist/ build/ @@ -103,3 +109,4 @@ normalized-*.jsonl # playwright-cli snapshots/console logs .playwright-cli/ +/.claude/scheduled_tasks.lock diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index c87c772a2b..2eaeb43474 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -66,15 +66,22 @@ func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *lo // LaunchAgent creates a new agentctl instance for a task. // Agent subprocess is NOT started - call StartAgentProcess() explicitly. func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { - // The RepositoryURL field contains a local filesystem path for the workspace - // If empty, the agent will run without a mounted workspace + // WorkspacePath wins when set (repo-less task with picked folder); otherwise + // fall back to RepositoryURL (legacy: this carries a local filesystem path + // for the workspace). Empty is also valid — lifecycle manager creates a + // scratch workspace. + workspacePath := req.WorkspacePath + if workspacePath == "" { + workspacePath = req.RepositoryURL + } launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, + WorkspaceID: req.WorkspaceID, SessionID: req.SessionID, TaskEnvironmentID: req.TaskEnvironmentID, TaskTitle: req.TaskTitle, AgentProfileID: req.AgentProfileID, - WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this + WorkspacePath: workspacePath, TaskDescription: req.TaskDescription, Attachments: convertToLifecycleAttachments(req.Attachments), Env: req.Env, diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index 933db633a1..07ad96e6a1 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -802,7 +802,12 @@ func (h *GitHandlers) wsCumulativeDiff(ctx context.Context, msg *ws.Message) (*w zap.String("session_id", req.SessionID), zap.String("base_commit", baseCommit)) } else { - return nil, fmt.Errorf("no base commit available for cumulative diff") + // Repo-less tasks (no git workspace) have no base commit — return an + // empty diff instead of erroring so the frontend's polling doesn't + // spam the logs. + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "cumulative_diff": nil, + }) } } diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index d6cf263188..a553fb362e 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -41,10 +41,12 @@ type Manager struct { worktreeMgr *worktree.Manager mcpProvider McpConfigProvider logger *logger.Logger - // dataDir is the base data directory for Kandev (from config.ResolvedDataDir()). - // Used for: + // dataDir is the kandev root directory. Misnamed for historical reasons: + // cmd/kandev/agents.go passes cfg.ResolvedHomeDir() (the kandev root — + // typically ~/.kandev) here, not ResolvedDataDir(). Used for: // - Session history storage (SessionHistoryManager) - // - Ephemeral workspace creation (quick chat, tasks without repositories) + // - Ephemeral workspace creation (quick chat) at /quick-chat/ + // - Scratch workspaces for repo-less tasks at /tasks// dataDir string // ExecutorRegistry manages multiple runtimes (Docker, Standalone, etc.) diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 1ca14afd30..9f7971a262 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -153,10 +153,13 @@ func (m *Manager) resolveWorkspaceFromProvider(ctx context.Context, req *LaunchR } func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) { - if req.UseWorktree && req.ACPSessionID == "" { + // Worktree mode requires a repository. Repo-less tasks fall through to the + // scratch workspace path below — even if the executor type was worktree. + useWorktree := req.UseWorktree && req.RepositoryPath != "" + if useWorktree && req.ACPSessionID == "" { return "", "", "", "" } - if req.UseWorktree && req.ACPSessionID != "" { + if useWorktree && req.ACPSessionID != "" { return m.resolveResumeWorktreePath(ctx, req) } workspacePath = req.WorkspacePath @@ -168,55 +171,78 @@ func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchReq return resolved, "", "", "" } } - // For ephemeral tasks without repositories (e.g., quick chat), create a workspace in ~/.kandev/quick-chat/ - // These directories are cleaned up when the ephemeral task is deleted (see task service performTaskCleanup). - // Non-ephemeral tasks should not receive a fallback workspace — they should fail loudly - // if no repository is configured (the MCP handler validates this for auto-start tasks). + // For tasks without a repository, create a scratch workspace. + // - Non-ephemeral repo-less tasks: /tasks/// + // (task-scoped, persists across sessions, mirrors the worktree task layout). + // - Ephemeral tasks (slack triage / quick chat): /quick-chat// + // (session-scoped, cleaned up on task delete via performTaskCleanup). if workspacePath == "" && req.SessionID != "" && m.dataDir != "" { - if !req.IsEphemeral { - m.logger.Warn("non-ephemeral task has no workspace path; skipping quick-chat fallback", - zap.String("task_id", req.TaskID), - zap.String("session_id", req.SessionID)) - return "", "", "", "" - } - quickChatDir := filepath.Join(m.dataDir, "quick-chat") - if err := os.MkdirAll(quickChatDir, 0755); err != nil { - m.logger.Warn("failed to create quick-chat directory, continuing without workspace", - zap.String("session_id", req.SessionID), - zap.String("quick_chat_dir", quickChatDir), - zap.Error(err)) - return "", "", "", "" - } - // Validate SessionID doesn't contain path separators (security: prevent path traversal) + workspacePath = m.resolveScratchWorkspace(ctx, req) + } + return +} + +// resolveScratchWorkspace creates and returns the scratch workspace path for a +// repo-less task. Returns empty string when the path could not be created. +func (m *Manager) resolveScratchWorkspace(ctx context.Context, req *LaunchRequest) string { + scratchPath := m.scratchWorkspacePath(req) + if scratchPath == "" { + return "" + } + if err := os.MkdirAll(scratchPath, 0755); err != nil { + m.logger.Warn("failed to create scratch workspace, continuing without workspace", + zap.String("session_id", req.SessionID), + zap.String("workspace_path", scratchPath), + zap.Error(err)) + return "" + } + if err := m.initGitRepo(ctx, scratchPath); err != nil { + m.logger.Warn("failed to initialize git repository in scratch workspace", + zap.String("session_id", req.SessionID), + zap.String("workspace_path", scratchPath), + zap.Error(err)) + // Continue anyway - git is optional for repo-less workspaces. + } + m.logger.Info("created scratch workspace", + zap.String("session_id", req.SessionID), + zap.String("task_id", req.TaskID), + zap.String("workspace_path", scratchPath)) + return scratchPath +} + +// scratchWorkspacePath computes the scratch workspace path for a launch request. +// Returns empty string if the inputs are invalid (path traversal guard, missing IDs). +func (m *Manager) scratchWorkspacePath(req *LaunchRequest) string { + if req.IsEphemeral { + // Legacy quick-chat path — session-scoped, kept for backward compat with + // slack triage and other ephemeral one-shot flows. if strings.ContainsAny(req.SessionID, `/\`) { m.logger.Warn("session ID contains path separator, rejecting", zap.String("session_id", req.SessionID)) - return "", "", "", "" + return "" } - // Use session ID as directory name for easy cleanup - workspacePath = filepath.Join(quickChatDir, req.SessionID) - if err := os.MkdirAll(workspacePath, 0755); err != nil { - m.logger.Warn("failed to create session workspace, continuing without workspace", - zap.String("session_id", req.SessionID), - zap.String("workspace_path", workspacePath), - zap.Error(err)) - return "", "", "", "" - } - - // Initialize git repository in the workspace (if not already initialized) - if err := m.initGitRepo(ctx, workspacePath); err != nil { - m.logger.Warn("failed to initialize git repository in quick chat workspace", - zap.String("session_id", req.SessionID), - zap.String("workspace_path", workspacePath), - zap.Error(err)) - // Continue anyway - git is optional for quick chat - } - - m.logger.Info("created quick chat workspace", - zap.String("session_id", req.SessionID), - zap.String("workspace_path", workspacePath)) + return filepath.Join(m.dataDir, "quick-chat", req.SessionID) } - return + // Non-ephemeral repo-less task: place under /tasks/// + // so it sits alongside repo-bound worktrees and persists across sessions. + if req.TaskID == "" || req.WorkspaceID == "" { + m.logger.Warn("scratch workspace requires task_id and workspace_id", + zap.String("session_id", req.SessionID), + zap.String("task_id", req.TaskID), + zap.String("workspace_id", req.WorkspaceID)) + return "" + } + if strings.ContainsAny(req.TaskID, `/\`) || strings.ContainsAny(req.WorkspaceID, `/\`) { + m.logger.Warn("task or workspace ID contains path separator, rejecting", + zap.String("task_id", req.TaskID), + zap.String("workspace_id", req.WorkspaceID)) + return "" + } + // m.dataDir is misnamed — cmd/kandev/agents.go passes cfg.ResolvedHomeDir() + // (the kandev root, e.g. ~/.kandev), not ResolvedDataDir(). So scratch + // workspaces live alongside the existing repo-bound worktree task dirs + // at /tasks///. + return filepath.Join(m.dataDir, "tasks", req.WorkspaceID, req.TaskID) } // launchPrepareRequest copies the launch request, sets the resolved workspace path, diff --git a/apps/backend/internal/agent/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/lifecycle/manager_launch_test.go index e8fdd9abec..c308ea00e6 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch_test.go @@ -2,6 +2,7 @@ package lifecycle import ( "context" + "path/filepath" "strings" "testing" "time" @@ -194,17 +195,70 @@ func TestLaunchResolveWorkspacePath_EphemeralCreatesQuickChatDir(t *testing.T) { require.Contains(t, workspacePath, "session-abc") } -func TestLaunchResolveWorkspacePath_NonEphemeralSkipsQuickChatDir(t *testing.T) { +func TestLaunchResolveWorkspacePath_NonEphemeralRepoLessGetsScratchDir(t *testing.T) { mgr := newTestManager() mgr.dataDir = t.TempDir() req := &LaunchRequest{ SessionID: "session-xyz", + TaskID: "task-xyz", + WorkspaceID: "ws-xyz", IsEphemeral: false, } workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req) - require.Empty(t, workspacePath, "non-ephemeral task without repo should NOT get a quick-chat workspace") + require.NotEmpty(t, workspacePath, "non-ephemeral task without repo should still get a scratch workspace") + // New layout: /tasks// (sibling to worktree task dirs). + require.Contains(t, workspacePath, filepath.Join("tasks", "ws-xyz", "task-xyz")) + require.NotContains(t, workspacePath, "quick-chat") +} + +func TestLaunchResolveWorkspacePath_NonEphemeralWithoutWorkspaceIDReturnsEmpty(t *testing.T) { + mgr := newTestManager() + mgr.dataDir = t.TempDir() + + // Non-ephemeral repo-less task missing workspace_id should not get a path + // (scratch path requires workspace + task IDs to namespace correctly). + req := &LaunchRequest{ + SessionID: "session-no-ws", + TaskID: "task-1", + IsEphemeral: false, + } + + workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req) + require.Empty(t, workspacePath) +} + +func TestLaunchResolveWorkspacePath_PickedFolderUsedDirectly(t *testing.T) { + mgr := newTestManager() + mgr.dataDir = t.TempDir() + picked := t.TempDir() // some existing folder the user picked + + req := &LaunchRequest{ + SessionID: "session-pick", + WorkspacePath: picked, + } + + workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req) + require.Equal(t, picked, workspacePath, "picked workspace_path should be used as-is, not replaced by scratch") +} + +func TestLaunchResolveWorkspacePath_WorktreeWithoutRepoFallsBackToScratch(t *testing.T) { + mgr := newTestManager() + mgr.dataDir = t.TempDir() + + // UseWorktree=true but no RepositoryPath — should not return empty, + // should fall through to the scratch workspace path. + req := &LaunchRequest{ + SessionID: "session-wt", + TaskID: "task-wt", + WorkspaceID: "ws-wt", + UseWorktree: true, + } + + workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req) + require.NotEmpty(t, workspacePath, "worktree-mode task without repo should fall through to scratch") + require.Contains(t, workspacePath, filepath.Join("tasks", "ws-wt", "task-wt")) } // TestLaunch_PromotesWorkspaceOnlyExecution verifies that when Launch finds an diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 0a39507ef9..c2b847c7b5 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -284,6 +284,7 @@ type RepoLaunchSpec struct { // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string + WorkspaceID string // Kandev workspace ID — used to build the scratch dir for repo-less tasks SessionID string TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) TaskTitle string // Human-readable task title for semantic worktree naming diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 31f85dcc7d..abad191a7e 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -229,6 +229,7 @@ type AgentProfileInfo struct { // LaunchAgentRequest contains parameters for launching an agent type LaunchAgentRequest struct { TaskID string + WorkspaceID string // Kandev workspace ID — used to build scratch dir for repo-less tasks SessionID string TaskEnvironmentID string // Env owning this session (shared across sessions in the same task) TaskTitle string // Human-readable task title for semantic worktree naming @@ -247,6 +248,7 @@ type LaunchAgentRequest struct { PreviousExecutionID string // Previous execution ID for runtime reconnect McpMode string // MCP tool mode: "config" activates config-mode tools IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation + WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback) // Setup script from executor profile (runs in execution environment before agent starts) SetupScript string diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 2d5362e9ea..29eb72bef6 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -252,7 +252,9 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi } isFirstSession := !hasPrimary - // Create agent session in database + // Create agent session in database. WorkspacePath is propagated from task + // metadata for repo-less tasks where the user picked a starting folder. + workspacePath, _ := task.Metadata[models.MetaKeyWorkspacePath].(string) sessionID := uuid.New().String() now := time.Now().UTC() session := &models.TaskSession{ @@ -261,6 +263,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi AgentProfileID: agentProfileID, RepositoryID: repositoryID, BaseBranch: baseBranch, + WorkspacePath: workspacePath, State: models.TaskSessionStateCreated, StartedAt: now, UpdatedAt: now, @@ -555,6 +558,7 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s sessionID := session.ID req := &LaunchAgentRequest{ TaskID: task.ID, + WorkspaceID: task.WorkspaceID, TaskTitle: task.Title, AgentProfileID: agentProfileID, TaskDescription: prompt, @@ -562,6 +566,7 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s SessionID: sessionID, TaskEnvironmentID: session.TaskEnvironmentID, IsEphemeral: task.IsEphemeral, + WorkspacePath: session.WorkspacePath, } execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 25a020d687..3dff3231bb 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -48,6 +48,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { // flow on the local executor. Path-only — fresh-branch is local-only. api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus) api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath) + api.GET("/fs/list-dir", h.httpListDirectory) api.GET("/repositories/:id", h.httpGetRepository) api.GET("/repositories/:id/branches", h.httpListRepositoryBranches) api.PATCH("/repositories/:id", h.httpUpdateRepository) @@ -136,6 +137,33 @@ func (h *RepositoryHandlers) httpDiscoverRepositories(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// httpListDirectory lists the immediate subdirectories of ?path= (defaults +// to $HOME). The picker deliberately allows browsing any directory the +// kandev process has read access to — kandev runs locally on the user's +// own machine, and the repo-less starting-folder flow legitimately wants +// /tmp, /var/log/foo, etc. Hidden (dotfile) directories are excluded. +func (h *RepositoryHandlers) httpListDirectory(c *gin.Context) { + path := c.Query("path") + result, err := h.service.ListDirectory(c.Request.Context(), path) + if err != nil { + // Log the raw OS error for debugging but return a generic message — + // otherwise we leak host paths and access patterns to the client (e.g. + // "open /home/user/private: permission denied"). + h.logger.Warn("failed to list directory", zap.String("path", path), zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to list directory"}) + return + } + entries := make([]gin.H, 0, len(result.Entries)) + for _, e := range result.Entries { + entries = append(entries, gin.H{"name": e.Name, "path": e.Path}) + } + c.JSON(http.StatusOK, gin.H{ + "path": result.Path, + "parent": result.Parent, + "entries": entries, + }) +} + func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) { path := c.Query("path") if path == "" { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 3e6be083a5..d48f10c4f6 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -394,6 +394,7 @@ type httpCreateTaskRequest struct { PlanMode bool `json:"plan_mode,omitempty"` Attachments []v1.MessageAttachment `json:"attachments,omitempty"` ParentID string `json:"parent_id,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` } type createTaskResponse struct { @@ -494,6 +495,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { Metadata: body.Metadata, PlanMode: body.PlanMode && !body.StartAgent, ParentID: body.ParentID, + WorkspacePath: body.WorkspacePath, }) if err != nil { handleNotFound(c, h.logger, err, "task not created") diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 1b58d39a59..0c58c88667 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -38,6 +38,10 @@ type SearchMessagesOptions struct { const ( MetaKeyAgentProfileID = "agent_profile_id" MetaKeyExecutorProfileID = "executor_profile_id" + // MetaKeyWorkspacePath is the optional host folder for repo-less tasks + // (set by CreateTask, read by the orchestrator when building a session). + // Centralised here so the set/read sites can't drift apart. + MetaKeyWorkspacePath = "workspace_path" ) // Task represents a task in the database @@ -281,6 +285,7 @@ type TaskSession struct { RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff) + WorkspacePath string `json:"workspace_path"` // Optional host folder for repo-less tasks (when user picked a starting folder) Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 2af9f03c44..3ccddf3742 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -195,6 +195,9 @@ func (r *Repository) runMigrations() error { _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) // Add hidden flag to workflows for system-only flows excluded from management UI (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`) + // Add workspace_path to task_sessions: optional host folder for repo-less + // tasks where the user pointed the agent at an existing directory. + _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN workspace_path TEXT DEFAULT ''`) return nil } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index cc5d3f5db8..fdef266249 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -212,13 +212,13 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id, - repository_id, base_branch, base_commit_sha, + repository_id, base_branch, base_commit_sha, workspace_path, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, review_status, is_passthrough, task_environment_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), session.ID, session.TaskID, session.AgentProfileID, - session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, + session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, @@ -255,7 +255,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr err := row.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, + &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, @@ -308,7 +308,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id @@ -324,7 +324,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id @@ -340,7 +340,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id @@ -379,13 +379,13 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?, - repository_id = ?, base_branch = ?, base_commit_sha = ?, + repository_id = ?, base_branch = ?, base_commit_sha = ?, workspace_path = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, completed_at = ?, updated_at = ?, is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ? WHERE id = ? `), session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, - session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, + session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt, dialect.BoolToInt(session.IsPrimary), session.ReviewStatus, @@ -494,7 +494,7 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id @@ -521,7 +521,7 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id @@ -546,7 +546,7 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id @@ -701,7 +701,7 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { err := rows.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, - &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, + &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, @@ -910,7 +910,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin SELECT ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, - ts.repository_id, ts.base_branch, ts.base_commit_sha, + ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id diff --git a/apps/backend/internal/task/service/directory_listing.go b/apps/backend/internal/task/service/directory_listing.go new file mode 100644 index 0000000000..9bbdfcc2bc --- /dev/null +++ b/apps/backend/internal/task/service/directory_listing.go @@ -0,0 +1,145 @@ +package service + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// DirectoryEntry is one immediate child of a listed directory. +type DirectoryEntry struct { + Name string + Path string // absolute path +} + +// DirectoryListing is the result of ListDirectory: the absolute path that was +// listed, the parent (empty when at the filesystem root), and the immediate +// subdirectory children sorted alphabetically. +type DirectoryListing struct { + Path string + Parent string + Entries []DirectoryEntry +} + +// ListDirectory returns the immediate subdirectories of path. When path is +// empty it falls back to $HOME. The picker is deliberately *not* anchored +// to discoveryRoots — users browsing for a starting folder for a repo-less +// task may legitimately want /tmp, /var/log/foo, or any other directory +// they have read access to. The repo-discover endpoint stays locked down +// to discoveryRoots; this one trusts the local-process boundary that runs +// kandev on the user's own machine. +// +// Filesystem operations go through os.Root opened at "/", which the Go +// stdlib enforces at the syscall level (no symlink escape, traversal in +// the relative path is rooted to "/"). That's a no-op for security because +// "/" is the filesystem root — but it's what CodeQL recognises as a +// path-injection sanitizer, so the lint stays green. +// +// Hidden (".") directories are excluded. +func (s *Service) ListDirectory(ctx context.Context, path string) (DirectoryListing, error) { + abs, err := resolveListingPath(path) + if err != nil { + return DirectoryListing{}, err + } + + entries, err := readSubdirsBoundedAtFilesystemRoot(abs) + if err != nil { + return DirectoryListing{}, err + } + + _ = ctx + return DirectoryListing{ + Path: abs, + Parent: parentPath(abs), + Entries: collectSubdirs(abs, entries), + }, nil +} + +// resolveListingPath cleans the user-supplied path. Empty path defaults to +// $HOME. The returned path is always absolute and cleaned. +func resolveListingPath(path string) (string, error) { + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home: %w", err) + } + return filepath.Clean(home), nil + } + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + return filepath.Clean(abs), nil +} + +// readSubdirsBoundedAtFilesystemRoot opens "/" via os.Root and reads the +// directory at the relative path inside it. Using os.Root here is for +// CodeQL's benefit: the Go stdlib refuses symlink escape and rejects +// traversal segments in the relative path, and CodeQL models os.Root.Open +// as a sanitizer for the path-injection query. Because the root is "/", +// containment is trivially satisfied — every absolute path on the host is +// inside it. +func readSubdirsBoundedAtFilesystemRoot(abs string) ([]os.DirEntry, error) { + root, err := os.OpenRoot("/") + if err != nil { + return nil, fmt.Errorf("open root: %w", err) + } + defer func() { _ = root.Close() }() + + rel := strings.TrimPrefix(abs, "/") + if rel == "" { + rel = "." + } + + info, err := root.Stat(rel) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("not a directory: %s", abs) + } + + dir, err := root.Open(rel) + if err != nil { + return nil, err + } + defer func() { _ = dir.Close() }() + return dir.ReadDir(-1) +} + +// parentPath returns the parent of abs, or "" when abs is the filesystem +// root. The picker's "up" button stops at "/" so the user can't navigate +// past the top of the host filesystem. +func parentPath(abs string) string { + parent := filepath.Dir(abs) + if parent == abs { + return "" + } + return parent +} + +// collectSubdirs filters entries to immediate subdirectories, drops hidden +// (dotfile) directories, and returns them sorted alphabetically (case-fold). +func collectSubdirs(parent string, entries []os.DirEntry) []DirectoryEntry { + out := make([]DirectoryEntry, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + out = append(out, DirectoryEntry{ + Name: name, + Path: filepath.Join(parent, name), + }) + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} diff --git a/apps/backend/internal/task/service/directory_listing_test.go b/apps/backend/internal/task/service/directory_listing_test.go new file mode 100644 index 0000000000..2d2d92d109 --- /dev/null +++ b/apps/backend/internal/task/service/directory_listing_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestListDirectory_ListsImmediateSubdirsOnly(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"alpha", "beta", "gamma"} { + if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + // File should not appear in listing. + if err := os.WriteFile(filepath.Join(root, "ignore-me.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + // Hidden directory should be excluded. + if err := os.Mkdir(filepath.Join(root, ".hidden"), 0o755); err != nil { + t.Fatalf("mkdir hidden: %v", err) + } + // Nested dir should NOT appear (immediate children only). + if err := os.MkdirAll(filepath.Join(root, "alpha", "deep"), 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + svc := &Service{} + got, err := svc.ListDirectory(context.Background(), root) + if err != nil { + t.Fatalf("ListDirectory: %v", err) + } + + want := []string{"alpha", "beta", "gamma"} + if len(got.Entries) != len(want) { + t.Fatalf("entries: got %d, want %d (%+v)", len(got.Entries), len(want), got.Entries) + } + for i, e := range got.Entries { + if e.Name != want[i] { + t.Errorf("entry[%d].Name = %q; want %q", i, e.Name, want[i]) + } + } + // A t.TempDir is not the filesystem root, so the parent should be set. + if got.Parent == "" { + t.Errorf("expected parent to be set for nested path, got empty") + } +} + +func TestListDirectory_DefaultsToHome(t *testing.T) { + svc := &Service{} + got, err := svc.ListDirectory(context.Background(), "") + if err != nil { + t.Fatalf("ListDirectory: %v", err) + } + home, _ := os.UserHomeDir() + if got.Path != filepath.Clean(home) { + t.Errorf("got Path = %q; want home %q", got.Path, home) + } +} + +func TestListDirectory_RejectsNonDirectory(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "not-a-dir") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + svc := &Service{} + _, err := svc.ListDirectory(context.Background(), file) + if err == nil { + t.Fatalf("expected error for non-directory path, got nil") + } +} + +func TestListDirectory_BrowsesOutsideHome(t *testing.T) { + // The picker deliberately lets users browse any directory the kandev + // process has read access to, not just $HOME or the discoveryRoots. + // /tmp is the canonical "outside $HOME but accessible" path on most + // Unix CI runners; assert we can list it without error. + svc := &Service{} + got, err := svc.ListDirectory(context.Background(), "/tmp") + if err != nil { + t.Fatalf("ListDirectory(/tmp): %v", err) + } + if got.Path != "/tmp" { + t.Errorf("got Path = %q; want /tmp", got.Path) + } +} + +func TestListDirectory_ParentEmptyAtFilesystemRoot(t *testing.T) { + svc := &Service{} + got, err := svc.ListDirectory(context.Background(), "/") + if err != nil { + t.Fatalf("ListDirectory(/): %v", err) + } + if got.Parent != "" { + t.Errorf("expected empty parent at /, got %q", got.Parent) + } +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 340f7ff79c..b045f25ae9 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -34,6 +34,7 @@ type CreateTaskRequest struct { PlanMode bool `json:"plan_mode,omitempty"` IsEphemeral bool `json:"is_ephemeral,omitempty"` // Ephemeral tasks are hidden from kanban, used for quick chat ParentID string `json:"parent_id,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` // Optional host folder for repo-less tasks } // UpdateTaskRequest contains the data for updating a task diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 0aebb37573..398cf0ce48 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -62,6 +62,14 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode if req.State != nil { state = *req.State } + metadata := req.Metadata + workspacePath := strings.TrimSpace(req.WorkspacePath) + if workspacePath != "" { + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata[models.MetaKeyWorkspacePath] = workspacePath + } task := &models.Task{ ID: uuid.New().String(), WorkspaceID: req.WorkspaceID, @@ -72,7 +80,7 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode State: state, Priority: req.Priority, Position: req.Position, - Metadata: req.Metadata, + Metadata: metadata, IsEphemeral: req.IsEphemeral, ParentID: req.ParentID, } diff --git a/apps/web/components/folder-picker.tsx b/apps/web/components/folder-picker.tsx new file mode 100644 index 0000000000..b4b0f4c880 --- /dev/null +++ b/apps/web/components/folder-picker.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { Fragment, useCallback, useEffect, useState } from "react"; +import { IconFolder, IconBox, IconChevronRight } from "@tabler/icons-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { listDirectory, type DirectoryListing } from "@/lib/api/domains/fs-api"; + +type FolderPickerProps = { + /** Currently chosen absolute path; empty when nothing is picked. */ + value: string; + /** Called with the absolute path when the user chooses a folder. */ + onChange: (path: string) => void; + placeholder?: string; +}; + +/** + * Folder picker for repo-less tasks. Drives GET /api/v1/fs/list-dir on the + * local kandev backend (browsers can't enumerate the host filesystem). The + * trigger lives in the chip row and the popover handles browse + commit. + */ +export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps) { + const [open, setOpen] = useState(false); + const { listing, loading, error, load } = useDirectoryListing(open, value); + const leaf = leafName(value); + const triggerLabel = leaf || placeholder || "scratch workspace"; + const hasValue = !!value; + + const triggerClass = cn( + "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs cursor-pointer", + "border border-border/60 transition-colors", + hasValue + ? "bg-primary/10 text-foreground hover:bg-primary/15" + : "bg-muted/30 text-muted-foreground hover:bg-muted/60", + ); + + const trigger = ( + + ); + + return ( + + + {hasValue ? ( + + {trigger} + {value} + + ) : ( + trigger + )} + + + void load(p)} /> + void load(p)} + /> +